Author SHA1 Message Date
jcwalker3 0f19773076 Merge branch 'master' into feat/issue-610-live-remote-parity 2026-07-21 20:48:54 -05:00
sysadmin 35e94e107c Merge pull request 'fix(mcp): block manual daemon killing as workflow recovery (Closes #630)' (#786) from fix/issue-630-daemon-kill-contamination into master 2026-07-21 18:04:05 -05:00
jcwalker3andClaude Opus 4.8 1ec4672fad fix(mcp): block manual daemon killing as workflow recovery (Closes #630)
A session that ran `pkill -f mcp_server.py`, waited for the IDE to respawn
the daemons, and then closed an issue left no trace distinguishing that
closure from one performed over a sanctioned runtime. Detection existed only
as advisory strings (`native_mcp_preference.classify_command_path`,
`review_workflow_boundary`) and never failed closed on the mutations that
followed.

Adds `runtime_recovery_guard.py`, mirroring the #671 stable-branch
contamination model so the two cannot drift apart: classification of
kill/pkill/killall commands and known-pid kills, a durable
`runtime_recovery_contamination` marker, a fail-closed pre-flight gate over
the shared review/merge/close/completion task set, and reconciler-only
clearing. `comment_issue` and `lock_issue` stay allowed so a contaminated
worker can still post its audit comment and hand off. The marker is
recovery-critical, so contamination cannot expire into cleanliness with the
session-state TTL.

Read-only inspection (`ps aux | grep mcp_server`), sanctioned reconnects,
and process management unrelated to the daemons are never flagged; a bare
`kill` of an unknown pid is reported as ambiguous rather than contaminating,
so ordinary subprocess work is not false-blocked. A pattern broad enough to
sweep unrelated namespaces (`pkill -f python`) is contamination even when it
never names MCP.

Operator-authorized host maintenance stays permitted, but the authorization
is read from GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION in the process
environment only and never from a tool argument, so a session cannot
authorize itself.

Final-report rules reject clean-session claims and require the contaminated
recovery to be surfaced. Two tools are registered (inventory 112 to 114) and
the sanctioned-versus-forbidden contrast is documented in the namespace
recovery doc and the workflow skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 17:54:19 -05:00
sysadmin 7ecf7bf2d6 Merge pull request 'feat(control-plane): persist allocator dependency edges as durable state (Closes #784)' (#785) from feat/issue-784-durable-dependency-edges into master 2026-07-21 17:22:07 -05:00
sysadminandClaude Opus 4.8 0589ec8069 feat(control-plane): persist allocator dependency edges as durable state (Closes #784)
Umbrella #628 scope item 6 requires dependencies to be durable structured
state carrying source, target, type, blocking condition, completion
condition, current state, and evidence. Nothing stored any of that.

Dependency knowledge existed only as a per-run computation:
allocator_dependencies re-parsed the Depends: declaration out of every
issue body on every allocation, _allocator_candidates_from_gitea resolved
each reference against live issue state, and the result collapsed into two
in-memory WorkCandidate fields that classify_skip consumed and discarded.
Three consequences followed. Nothing could answer "what is waiting on #N"
without re-listing every open issue and re-parsing every body, so the
reverse edge automatic resumption needs did not exist in any form. Only
issue-blocked-by-issue was expressible, leaving the other six #628
relationships with nowhere to live. And no observation was recorded, so a
transient lookup failure and a real block were indistinguishable after the
fact.

Add the store:

- dependency_edges table under schema v4. Creating the table is itself the
  v3 to v4 migration: additive, idempotent, and it never touches the
  existing tables. Uniqueness is (scope, source, target, edge_type), so
  re-observation updates one row rather than appending duplicates.
- dependency_graph.py owns the vocabulary: the seven #628 relationship
  types, the three states, and fail-closed normalization for both plus
  endpoint kinds. An unrecognized value writes nothing rather than landing
  as unqueryable free text. Evidence is sanitized before storage, so no
  credential or endpoint URL can be persisted or read back.
- upsert_dependency_edge, list_dependency_edges, and
  record_dependency_edge_observation on ControlPlaneDB. Filtering by target
  makes reverse lookup a single query. State transitions append to the
  existing events table rather than a parallel audit table.
- The allocator persists what it already resolved. States map one-to-one
  from the resolver's met/unmet/unavailable partitions, so nothing is
  re-classified and unavailable evidence is never recorded as met.
- gitea_list_dependency_edges exposes stored edges read-only, gated on
  gitea.read, and is added to the documented inventory the #781 drift guard
  checks.

Selection is deliberately untouched: classify_skip still consumes the
in-memory dependency_unmet field. The write is best-effort and reports
failures through reasons, so a broken or absent store leaves allocation
behaving exactly as it did before — proven by allocating the same candidate
set through a store whose writes all raise and comparing the selection,
skip set, and candidate count.

Automatic blocking and resumption (#628 item 7), non-issue edge creation,
and defect auto-linking are later slices; this one only makes the graph
durable and queryable.

Tests: 31 new cases covering fresh-schema creation, a real v3-to-v4
migration with row retention, idempotent re-migration, enum rejection,
upsert idempotence, forward and reverse lookup, scope isolation, transition
events, redaction at rest, live allocation-run ingestion, write-failure
tolerance, and the tool's permission gate.

Verification: 4126 passed in the branch worktree. The 11 failures in
test_commit_payloads, test_issue_702_review_findings_f1_f6, test_mcp_server,
test_post_merge_moot_lease, and test_reconciler_supersession_close reproduce
identically on a clean detached checkout of master at 300e8acd, so they are
pre-existing and proven by baseline run, not introduced here.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 18:07:21 -04:00
sysadmin 300e8acd13 Merge pull request 'fix(mcp): add sanctioned gitea_edit_issue and a doc/registry drift guard (Closes #781)' (#783) from fix/issue-781-mcp-edit-issue-tool into master 2026-07-21 16:15:01 -05:00
sysadminandClaude Opus 4.8 a002864a06 fix(mcp): add sanctioned gitea_edit_issue and a doc/registry drift guard (Closes #781)
The gitea-workflow documentation named a gitea_edit_issue tool that no
namespace had ever registered. There was no sanctioned MCP path to change an
issue title or body at all: the only edit tool, gitea_edit_pr, PATCHes the
pull-request endpoint. An authorized body correction on issue #780 therefore
had to be recorded as a discussion comment instead.

Add edit_issue.py as the authoritative rule and gitea_edit_issue as the tool
built on it:

- Only the fields the caller names are sent, so labels, state, assignee, and
  milestone cannot be overwritten from a stale read.
- A pull-request number is refused. Gitea serves pull requests from the same
  /issues/{n} collection, so without that check the issue path would quietly
  become a second, ungated PR edit path. gitea_edit_pr stays PR-only.
- Structurally invalid requests raise before any credential or network work;
  a request that would change nothing is reported as an explicit no-op with a
  next action rather than a silent success.
- Read-after-write proves the applied title/body and proves that state,
  labels, assignees, and milestone did not move. Transport failures on the
  pre-read, the PATCH, and the read-back are each reported, redacted, with
  the correct performed/verified state.

Gates match every other issue mutation: profile permission via the shared
capability map (resolver task edit_issue, gitea.issue.comment), preflight
purity, branches worktree validation, anti-stomp inventory membership, and
audited mutation.

Fix the drift that hid this. docs/mcp-tool-inventory.md is now the canonical
registered-tool list, and mcp_tool_inventory.py compares it to the live
registry in both directions, plus checks that every tool named under skills/
is registered. The new guard immediately found a second instance of the same
defect: gitea_record_pre_review_command had lost its @mcp.tool() decorator
while the canonical review workflow still instructed reviewers to call it, so
that registration is restored.

Validation:
PASSED: venv/bin/python -m pytest tests/test_issue_781_edit_issue_tool.py -s -q
  — 50 passed
FAILED: venv/bin/python -m pytest -s -q — 4095 passed, 11 failed, 6 skipped.
  The identical 11 tests fail on clean master 8e149e6 with no changes applied
  (254 passed, 11 failed across those five files), so they are pre-existing
  and proven by a baseline run rather than asserted.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 16:52:23 -04:00
sysadmin 8e149e6cfa Merge pull request 'fix(workflow): retire status:pr-open on every terminal PR transition (Closes #780)' (#782) from fix/issue-780-terminal-pr-open-label-cleanup into master 2026-07-21 15:14:21 -05:00
sysadminandClaude Opus 4.8 ed0e8c82de fix(workflow): retire status:pr-open on every terminal PR transition (Closes #780)
status:pr-open was applied by gitea_create_pr and never removed again. Every
terminal path finished without touching it, so a repository audit found 40
closed issues still advertising an open PR.

Add terminal_pr_label_cleanup.py as the single authoritative rule and route
every sanctioned terminal path through it, so the paths cannot drift:

- merge (gitea_merge_pr)
- close without merge (gitea_edit_pr)
- supersession/abandonment (gitea_reconcile_superseded_by_merged_pr)
- already-landed reconciliation (gitea_reconcile_already_landed_pr)
- controller closure (gitea_close_issue)
- retry/recovery (new gitea_cleanup_terminal_pr_labels)

The rule removes only status:pr-open, preserves every other label, allows an
empty resulting set, is a no-op when the label is absent (so retries are
safe), and confirms the outcome by read-after-write rather than assumption.

Controller closure runs the cleanup before the state change and fails closed
if it cannot be completed and verified; closing first would bake in the stale
label with no later step to catch it. Post-merge cleanup never blocks the
merge, which already happened, and reports failures with a safe next action.

Also:
- gitea_assess_terminal_label_hygiene: read-only terminal validation that
  reports residual status:pr-open, exempting issues with a genuinely open PR.
- _put_issue_label_names now accepts Gitea's empty response body when the
  requested set is empty, so clearing the last label works.
- test_audit's close_issue fixture keys on the request instead of call order,
  since closing now also reads labels for the cleanup and its read-back.

Docs: label-taxonomy terminal-transition section, runbook pointer, and the
review-merge / reconcile-landed final-report terminal-label requirements.

Suite: 4045 passed, 11 failed, 6 skipped. The same 11 failures reproduce on
clean master df31674 (4010 passed, 11 failed) and are pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 15:41:24 -04:00
sysadmin df3167488c Merge pull request 'feat: enforce self-propagating canonical handoffs through controller closure (Closes #626)' (#779) from feat/issue-626-self-propagating-handoffs into master 2026-07-21 12:53:50 -05:00
jcwalker3andClaude Opus 4.8 ddc9b97d40 feat: enforce self-propagating canonical handoffs through controller closure (Closes #626)
Adds the canonical cross-role handoff schema and its fail-closed validator,
live-state recovery, role-limited continuation, mandatory durable posting,
the merged-awaiting-controller boundary, controller accept/reject
continuation, and workflow-failure escalation with duplicate handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 02:33:51 -05:00
sysadmin 1d11cbab0f Merge pull request 'fix(allocator): pre-rank exclusions and candidates_json transport (Closes #776)' (#777) from fix/issue-776-allocator-pre-rank-exclusions into master 2026-07-20 22:33:05 -05:00
sysadminandClaude Opus 4.8 d17f055e86 fix(allocator): pre-rank exclusions and candidates_json transport (#776)
Expose exclude_issue_numbers on gitea_allocate_next_work, remove excluded
numbers before ranking, normalize decoded-list and JSON-string
candidates_json fail-closed, and return candidate-set fingerprints for
dry-run/apply CAS. Same-owner leases on excluded issues surface a
structured resume/release blocker.

Closes #776

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 22:40:39 -04:00
sysadmin 52ded0ea71 Merge pull request 'fix: mutation budget counts server-side changes only (Closes #617)' (#775) from fix/issue-617-mutation-budget-classifier into master 2026-07-20 20:28:32 -05:00
jcwalker3andClaude Opus 4.8 296601647d fix: mutation budget counts server-side changes only (Closes #617)
Auto-mode classifier now distinguishes local validator rejection,
capability-gate rejection, transport failure before API, and successful
server-side mutation. Pre-API validator failures no longer consume
server-side mutation budget; the final report separately accounts for
local failed attempts, blocked API attempts, and successful server-side
mutations.

Recovered from preserved unpublished commit b46f0f9 via native MCP
unpublished-claim recovery (#772) and author-worktree lock binding (#618),
reconciled onto current master.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 18:58:40 -05:00
sysadmin 702ceb2480 Merge pull request 'fix(mcp): recover clean unpublished author work after the owning session exits (Closes #772)' (#774) from fix/issue-772-unpublished-claim-recovery into master 2026-07-20 15:52:07 -05:00
jcwalker3andGrok 4.5 6c15aa88b3 test(mcp): AC9 assessor/mutator parity + AC6 unpublished recovery MCP regressions (PR #774 F1/F2)
Remediate review 487 F1/F2: add AC9 assessor/mutator parity regressions and AC6 MCP-level unpublished-claim recovery regressions through gitea_lock_issue. Tests only; no production code change.

Closes nothing; remediates PR #774 review findings only.

Co-Authored-By: Grok 4.5 <[email protected]>
2026-07-20 15:38:11 -05:00
sysadminandGrok 4.5 c31df2130c test(mcp): AC9 assessor/mutator parity + AC6 unpublished recovery MCP regressions (PR #774 F1/F2)
Local worktree commit only (publication blocked by dangling GITEA_AUTHOR_WORKTREE).
Tests only; no production code change.

Co-Authored-By: Grok 4.5 <[email protected]>
2026-07-20 16:13:53 -04:00
sysadmin ccfaa0ec0c Merge pull request 'fix: exclude foreign-claimed work from allocation (Closes #765)' (#773) from fix/issue-765-allocator-foreign-lease into master 2026-07-20 14:24:58 -05:00
jcwalker3andClaude Opus 4.8 (1M context) &lt;[email protected]&gt; ca76dacd73 fix(mcp): recover clean unpublished author work after the owning session exits
Closes #772

Recovery now dispatches on observed publication state: published_owning_pr
(remote branch exists; head equality #753 or strict descendancy #768,
unchanged) and unpublished_claim (no remote branch, no PR; ownership proven
by the durable lock record plus a local HEAD strictly descending from the
server-observed base the branch was cut from).

The absence of a remote head is never itself permission. Recovery writes are
compare-and-swap against a lock generation, and the mutating lock path and
read-only diagnostic assessor share one evaluator.

Co-Authored-By: Claude Opus 4.8 (1M context) &lt;[email protected]&gt;
2026-07-20 14:18:25 -05:00
jcwalker3andClaude Opus 4.8 ad13d872df fix: exclude foreign-claimed work from allocation instead of blockading the queue (Closes #765)
Recovered preserved candidate d06198b onto current master 0c2f45a via clean
cherry-pick. Active foreign leases are excluded before allocator ranking;
controller_instance_id ownership is persisted; dashboard matches allocator
exclusion. Stable patch-id bacafc5f… preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 13:52:39 -05:00
sysadmin 0c2f45abb7 Merge pull request 'fix: durable author worktree resolution without control fallback (Closes #618)' (#771) from fix/issue-618-author-worktree-resolution into master 2026-07-20 13:10:26 -05:00
jcwalker3andClaude Opus 4.8 5ed2ab8a38 fix: durable author worktree resolution without control fallback (Closes #618)
Author mutation tools now resolve workspace via explicit worktree_path,
env bindings, or the active author issue lock — never silent fallback to
the control checkout/master. Missing configured bindings fail closed with
operator recovery; create_issue and create_issue_comment agree.

Recovered onto 0568f44 from preserved candidate cbf56ccd (AUTHOR_RECOVERY).
LLM_LOCK_ID=author-618-recovery-508eb3162d01-1784569919

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 12:55:40 -05:00
sysadmin 0568f44cb2 Merge pull request 'feat: enforce stable-control vs dev runtime modes for Gitea MCP (Closes #615)' (#770) from feat/issue-615-runtime-mode-enforcement into master 2026-07-20 12:36:57 -05:00
sysadminandClaude Opus 4.8 ab34280f90 fix: correct runtime-gate alignment and per-call state for #615 (Closes #615)
Remediates reviewer findings F1, F2, and F3 from review 483 on PR #770.

F1 (blocking): _current_runtime_mode_report derived the runtime-gate alignment
input as realpath(workspace_root) == process_project_root, redefining
workspace_roots_aligned. Its established meaning is ctx["roots_aligned"]
(canonical_repo_root == process_project_root) — a repository-level question.
Because the global worktree rule requires all task work to live in a branches/
worktree, the old derivation reported aligned False for exactly the sessions
that are configured correctly, raising unsafe_process_root_workspace_alignment
and refusing every non-read operation once an author, reviewer, or merger
namespace held a worktree binding. The gate is now fed ctx["roots_aligned"].

F2 (blocking): _current_runtime_mode_report cached its first result into
_STARTUP_RUNTIME_MODE, which was initialised to None rather than captured at
import, and the read-only refresh=True path seeded it too. Session-scoped
fields (active_task_workspace and its derived alignment) and mutable fields
(dirty_files) were frozen for the process lifetime, so the acceptance
criterion 7 dirty-runtime blocker stopped applying after the snapshot and one
session's binding decided alignment for every later session.

Immutable process facts (process root, branch, head, checkout-ness) are now
captured at import as _STARTUP_RUNTIME_FACTS, matching the #420 parity
baseline. Dirty state, workspace binding, and alignment are recomputed on every
call. A new stable_control_runtime.observe_dirty_files() splits out the one
runtime fact that legitimately changes during a process lifetime;
observe_runtime() delegates to it so the parsing lives in one place. refresh is
retained for the read-only reporting path but no longer selects a cache, so a
read-only call can neither seed nor weaken a later mutation decision.

F3 (major): no test exercised the real derivation — every server-wiring test
asserting a healthy runtime permits mutations patched
_current_runtime_mode_report with a fixture whose workspace_roots_aligned was
True. New TestServerWiringRealDerivation patches only the derivation's inputs
and lets the real function build the report:

- a clean stable control checkout plus a correctly bound branches/ worktree
  permits an otherwise authorized mutation;
- misaligned process/canonical roots fail closed;
- newly dirty task state is detected after an earlier clean read;
- a read-only refresh cannot freeze a permissive mutation result;
- an unresolvable binding reports unknown alignment, never alignment proof.

Acceptance criteria 6, 8, 10, and 11 are unchanged and untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 12:28:07 -04:00
sysadminandClaude Opus 4.8 9bf3acfef6 feat: enforce stable-control vs dev runtime modes for Gitea MCP (Closes #615)
The stable-control runtime ADR (docs/architecture/mcp-stable-control-runtime-policy-adr.md)
established the policy but had no runtime enforcement: a daemon relaunched from a
feature worktree still holds production credentials and will happily mutate real
issues. This adds the enforcement layer (acceptance criteria 6-11).

stable_control_runtime.py:
- classify_runtime_mode(): stable-control | dev-test | unknown, inferred from the
  process root and checkout branch, with an explicit GITEA_MCP_RUNTIME_MODE
  declaration for packaged layouts that have no git checkout.
- build_runtime_report(): runtime mode, git SHA, branch, checkout path, process
  root, active workspace, repo binding, profile, identity, dirty files,
  alignment, and real_mutations_allowed.
- assess_runtime_mutation_gate(): fails closed on dev-test targeting production,
  unknown runtime, dirty stable checkout, dev-worktree launch, and unsafe
  process-root/workspace alignment.
- Post-transport-flap re-proving tracked per namespace, so proving the author
  namespace never implies reviewer, merger, or reconciler (#584).
- assess_promotion_record(): promotion must record previous and promoted SHAs
  plus health, identity, profile, workspace, capability, and rollback proof.

Server wiring:
- _profile_operation_gate() consults the runtime gate alongside the #420 parity
  gate. gitea.read is never blocked, so an operator can still diagnose a sick
  runtime.
- The gate reads a startup snapshot rather than shelling out per mutation, for
  the same reason parity uses a startup baseline: the runtime a process serves
  from is fixed when it loads its code. Enforcement is decided from how the
  process was loaded, so per-test production simulation cannot switch it on.
- gitea_get_runtime_context() reports the live runtime under
  stable_control_runtime and points at the promotion runbook when blocked.

Docs and tooling:
- docs/stable-runtime-promotion-runbook.md: operator promotion procedure,
  required record fields, per-namespace re-proving, rollback.
- scripts/promote-stable-runtime: read-only helper that emits and validates a
  promotion record; it never restarts anything.
- Five canonical [THREAD STATE LEDGER] examples: runtime healthy, transport flap
  recovered, namespace not yet re-proven, promotion completed, rollback required.
- ADR section 5 follow-ups marked landed; runbooks cross-link the new runbook.

Validation: 47 new tests in tests/test_stable_control_runtime.py. Full suite
3827 passed / 6 skipped / 2 failed; both failures are pre-existing on master
059ee77 (verified in a clean baseline worktree: identical 2 failures,
3780 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 10:48:47 -04:00
sysadmin 059ee77c1f Merge pull request 'feat: add Sentry-to-Gitea incident bridge for MCP workflow failures (Closes #607)' (#767) from feat/issue-607-sentry-incident-bridge into master 2026-07-20 04:45:10 -05:00
jcwalker3 bc968dd2e0 fix: post AC4 recurrence comments on linked incident issues (#607)
Remediates review 479/481 findings F1 and F2 on PR #767.

F1: issue #607 AC4 requires that an existing linked Gitea issue receives a
recurrence comment when Sentry events continue. That path did not exist.
This adds:

- incident_bridge.incident_recurred() — true only when event_count or
  last_seen genuinely advanced, compared against the pre-upsert link row, so
  an unchanged rescan stays silent and the exactly-once property holds.
- incident_bridge.build_recurrence_comment_body() — reuses the same
  redact_text path as build_gitea_issue_body, so redaction is not
  re-implemented.
- A comment_issue_fn hook on reconcile_incident, invoked only on
  OUTCOME_UPDATED with genuinely new events. The durable incident_links row
  is written first, so a comment failure degrades to "link updated, comment
  withheld" without corrupting the mapping or creating a duplicate issue.
- _incident_recurrence_comment_fn() in gitea_mcp_server.py, routing through
  the sanctioned gitea_create_issue_comment path named in issue #607. It
  returns None on dry runs and when the profile lacks gitea.issue.comment, so
  the AC8 dry-run default and disabled-mode safety hold.
- comment_issue_fn threading through sentry_incident_bridge.watchdog(), with
  the per-issue recurrence_comment outcome recorded in the scan result.

F2: observation_from_issue never populates a fingerprint, so the "deduped by
Sentry issue id and fingerprint" claim was unsupported. The
gitea_sentry_reconcile_issue and gitea_sentry_watchdog docstrings now state
the real dedupe basis: provider identity (provider, base URL, org, project,
Sentry issue id).

Tests: five new AC4 cases in tests/test_sentry_incident_bridge.py covering a
comment on the second scan, dry-run silence, no-new-events silence, link
durability when the comment fails, and comment redaction. Focused suite
43 passed (was 38).

Refs #607
2026-07-20 04:08:52 -05:00
jcwalker3 716fc21a0d Merge branch 'master' into feat/issue-607-sentry-incident-bridge 2026-07-20 03:08:51 -05:00
sysadmin edaeede250 Merge pull request 'fix(mcp): accept strict-descendant dead-session recovery (Closes #768)' (#769) from fix/issue-768-descendant-recovery into master 2026-07-20 03:05:39 -05:00
jcwalker3andClaude Opus 4.8 5547399037 fix(mcp): accept strict-descendant dead-session recovery (Closes #768)
Permit fail-closed recovery when a clean local head is a strict
descendant of the recorded PR/remote head, with server-side ancestry
proof. Propagate recovery evidence through commit, push, and PR
duplicate gates so an owning PR is not re-blocked as competing work.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 02:52:29 -05:00
sysadmin cb6ae0ca50 Merge branch 'master' into feat/issue-607-sentry-incident-bridge 2026-07-20 01:11:57 -04:00
sysadmin d12adabeb1 Merge pull request 'fix(mcp): enforce role capability invariants' (#766) from fix/issue-723-role-stamp-invariants into master 2026-07-19 23:38:49 -05:00
sysadmin 4b8a9219d8 fix(mcp): enforce role capability invariants (Closes #723) 2026-07-19 23:21:54 -04:00
sysadminandClaude Opus 4.8 e168978579 feat: add Sentry-to-Gitea incident bridge for MCP workflow failures (Closes #607)
Adds the read half of the inbound observability path: pull unresolved issues
and events from the self-hosted Sentry API, normalize them into #612
observations, and reconcile them into durable Gitea issues.

Design: the existing #612 incident_bridge already owns dedupe, linking,
redaction, and issue creation on the #613 incident_links substrate, so this
change adds only what was genuinely missing - a Sentry API read layer,
observation mapping, a policy gate, and a watchdog. No second linking store is
introduced, which is what makes the mapping survive restarts (AC6).

New module sentry_incident_bridge.py:
* list_issues() with Link-header cursor pagination and statsPeriod windowing
* get_issue_events() returning sanitized recent + latest event
* observation_from_issue() mapping onto the #612 observation contract
* should_bridge_issue() policy gate (unresolved + event-count threshold)
* watchdog() scanning and reconciling, dry-run by default
* HTTP access injected as http_fn so the surface is testable without Sentry

New MCP tools: gitea_sentry_list_issues, gitea_sentry_get_issue_events,
gitea_sentry_reconcile_issue, gitea_sentry_link_gitea_issue,
gitea_sentry_watchdog.

Safety:
* SENTRY_AUTH_TOKEN is read from the environment only and never returned,
  logged, or stored; config projections cannot carry it by construction
* missing config fails closed as not_configured, and a missing token fails
  closed as missing_token before any HTTP call is made
* apply=true requires both MCP_SENTRY_ISSUE_BRIDGE_ENABLED and issue-create
  permission; Sentry outages create nothing
* secrets are redacted and absolute local paths reduced to a category token
  before any value can reach a Gitea issue body
* raw Sentry incidents are never assignable control-plane work items

Tests: 38 new cases covering create, update/recurrence, dedupe, resolved-issue
non-reopen, redaction, pagination, missing token, unavailable server,
self-hosted base URL, restart persistence, policy gates, and the five tool
wrappers.

Full suite: 3733 passed, 6 skipped. The 2 remaining failures
(test_issue_702_review_findings_f1_f6, test_reconciler_supersession_close) were
verified to fail identically on unmodified master at fcf6981 and are unrelated
to this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_011w1WGVV3duWEf45SJRJ1DL
2026-07-19 22:35:18 -04:00
sysadmin fcf6981b1b Merge pull request 'feat: add workflow dashboard for queue, leases, and next safe action (Closes #605)' (#762) from feat/issue-605-workflow-dashboard into master 2026-07-19 19:20:19 -05:00
jcwalker3 d181d499d3 Merge branch 'master' into feat/issue-605-workflow-dashboard 2026-07-19 18:54:17 -05:00
sysadmin b7a5284b98 Merge pull request 'fix: accept canonical reviewer lease preflight order (Closes #763)' (#764) from fix/issue-763-reviewer-lease-preflight-order into master 2026-07-19 17:52:59 -05:00
sysadmin 6b568d8805 fix: accept reviewer lease preflight transition (Closes #763) 2026-07-19 18:27:34 -04:00
sysadmin 7f2b9f36de feat: add workflow dashboard for queue, leases, and next safe action (Closes #605)
Read-only MCP tool gitea_workflow_dashboard plus mcp-menu entry so operators
and LLMs can see PR/issue queues, leases, terminal locks, blockers, and exact
next-safe prompts without reconstructing state from comments. Never assigns
work or presents blocked/terminal-locked items as safe.
2026-07-19 14:43:10 -04:00
sysadmin 8a851eb87e Merge pull request 'fix: rank complete allocator inventory and resolve declared dependencies (Closes #758)' (#761) from fix/issue-758-allocator-dependency-aware into master 2026-07-19 13:32:17 -05:00
sysadmin ad59053cd7 fix: rank complete allocator inventory and resolve declared dependencies (Closes #758)
The author allocator could select an ineligible issue for two independent
reasons, both fixed here.

Defect 1 — candidate truncation before ranking. The loader fetched the
complete open inventory via api_get_all, then sliced it to `limit` items
before constructing any WorkCandidate. Because every status:ready issue
ties at priority 20 and the tie breaks on lowest number, the slice — not
the ranking — decided the winner, so the same query returned different
answers at different `limit` values. Candidate construction now consumes
the full listing; `limit` bounds the reported skip list only, and any
such truncation is reported explicitly rather than silently.

Defect 2 — dependency state inferred from body substrings. Only
"blocked on #" and "downstream of #" were recognized, so the repository's
canonical `Depends: #N, #N` field never set dependency_unmet and
dependency-blocked issues were emitted as eligible. The new
allocator_dependencies module parses the canonical declaration into
structured references and resolves each against live issue state: open
means unmet, closed means met, and unavailable evidence fails closed. The
complete open listing proves openness without extra calls; anything
absent from it is confirmed by a cached targeted lookup instead of being
assumed closed. The legacy "blocked on #" marker still parses.

A failed listing now marks the inventory incomplete and the tool fails
closed instead of ranking a partial set.

Selection already advanced past a skipped candidate; with dependencies
resolved correctly that fall-through now actually engages, and is covered
by a regression.

Tests: 35 new cases across parser, resolver, loader, and an MCP-level
gitea_allocate_next_work regression, including limit-invariant selection
over a 73-candidate inventory and a guard proving pre-ranking truncation
would change the winner. No issue number is special-cased.
2026-07-19 14:12:04 -04:00
sysadmin 854818e65a Merge pull request 'fix(mcp): honor the create-issue bootstrap in anti-stomp preflight (Closes #757)' (#759) from fix/issue-757-create-issue-bootstrap-anti-stomp into master 2026-07-19 12:34:07 -05:00
sysadminandClaude Opus 4.8 9d2c652ae8 fix(mcp): require proven base equivalence for the create-issue bootstrap
Remediates review #473 (REQUEST_CHANGES) on PR #759 / issue #757 AC3/AC4.

The bootstrap compared SHAs only inside:

    if remote_tip and local_tip and remote_tip != local_tip:

so agreement was assumed whenever either tip was unknown. A missing local
HEAD, an unresolvable live master, or a resolver exception silently granted
the control-checkout exemption instead of blocking it. An unknown tip is
missing evidence, not proof of equivalence.

Changes:

* assess_create_issue_bootstrap now blocks unless BOTH tips are known and
  equal, with distinct reasons for missing local HEAD, unknown live master,
  and resolver failure. Adds a remote_master_sha_error parameter so a failed
  resolution is reported as missing evidence rather than absence of a
  constraint.
* Both normalized SHAs and a derived base_tips_verified flag are recorded on
  the assessment. normalize_sha() treats only case and surrounding whitespace
  as equivalent spellings of a commit.
* bootstrap_permits_control_checkout re-derives the comparison from the
  recorded tips instead of trusting base_tips_verified, so a hand-built or
  truncated assessment cannot assert agreement it never proved.
* _create_issue_bootstrap_assessment captures the resolver exception and
  forwards it, replacing the silent except -> None.

One shared assessment still serves both the #274 and #604 guards, and
_BOOTSTRAP_UNSET is preserved. No MCP tool signature gains a bootstrap
argument (AC6). No issue or PR number is special-cased (AC10).

Tests: 16 new assertions across two classes covering missing local, missing
remote, both missing, empty/whitespace tips, mismatch, resolver exception at
the assessment site, and resolver exception through the real
verify_preflight_purity path; plus predicate rejection of stripped tips, a
forged base_tips_verified flag, and a missing flag. All 16 fail against the
sources at adc61255 and pass after this change. The valid non-create_issue
lock_issue test is retained unchanged.

Validation: focused #757 suite 51 passed (31 subtests); affected guard and
preflight suites 217 passed (67 subtests); full suite 3637 passed, 2 failed,
6 skipped, 431 subtests. Both failures (test_issue_702 F1 worktree recovery;
test_reconciler_supersession_close org/repo forwarding) are the documented
pre-existing baseline failures on bde5c5fb and are not caused by this change.

Refs #757

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_015KRvvrFtaM5FEvQ6LvDJMH
2026-07-19 12:59:08 -04:00
sysadminandClaude Opus 4.8 adc61255b2 fix(mcp): honor the create-issue bootstrap in anti-stomp preflight (Closes #757)
The sanctioned create_issue bootstrap from #749/#750 was unreachable in
production. Two guards assessed the same workspace for the same task and
reached opposite conclusions: the #274 branches-only guard consulted the
bootstrap and permitted a clean canonical control checkout, then the #604
anti-stomp preflight -- which never consulted it -- rejected that same
checkout as wrong_worktree.

The defect was wiring, not policy: the bootstrap decision was computed in
one guard and discarded, while the other re-derived a conflicting answer
from a lower-level assessor with no notion of the bootstrap phase.

Fix: one computation site, one interpretation site.

* create_issue_bootstrap.bootstrap_permits_control_checkout() is the single
  predicate both guards use to interpret an assessment. It is fail-closed by
  construction: missing, malformed, refused, incomplete, or contradictory
  evidence returns False and leaves the ordinary block in force. It also
  verifies the assessment describes the exact workspace and canonical root
  being guarded, so a stale or foreign assessment cannot be reused.
* _create_issue_bootstrap_assessment() computes the assessment once per
  preflight from inspected repository state. verify_preflight_purity threads
  that single result into both guards.
* The #604 assessor accepts the assessment and waives ONLY the wrong-worktree
  verdict. Root checkout, repo, role, stale runtime, lease, head-SHA,
  workflow-hash, and contamination checks are evaluated independently and
  still apply.

Evidence is server-derived only and travels an internal path: no MCP tool
signature gains a bootstrap argument, and no caller-controlled boolean can
manufacture eligibility. Behavior is unchanged for callers that supply no
evidence, and for every non-create_issue author mutation.

No issue or PR number is special-cased in production behavior.

Tests: new tests/test_issue_757_bootstrap_guard_agreement.py (38 tests, 26
subtests) covering the shared predicate, the narrow waiver, guard agreement
across the full workspace-state matrix, non-forgeable eligibility, and an
end-to-end native gitea_create_issue run with the #604 gate LIVE. All 38
fail against unfixed sources; the e2e reproduces the production error text
verbatim ("Anti-stomp preflight (#604) blocked mutation [wrong_worktree]").

tests/test_reconciler_close_workspace_guard.py: one case asserted that
create_issue stays blocked on the control checkout, which only held because
the bootstrap-blind #604 guard was overriding #750 -- it encoded the defect.
Re-pointed to lock_issue, which is issue-backed and legitimately still
requires a branches/ worktree. Its teardown now restores the module-level
preflight task/role so test order cannot leak resolved state.

Full suite: 3624 passed, 2 failed, 6 skipped (426 subtests).
Baseline at bde5c5fb on a clean detached worktree: 3586 passed, 2 failed,
6 skipped (400 subtests). The same 2 failures reproduce identically on
pristine master and are unrelated to this change
(test_issue_702_review_findings_f1_f6 F1 worktree recovery;
test_reconciler_supersession_close org/repo forwarding).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-19 02:26:58 -04:00
sysadmin bde5c5fb20 Merge pull request 'fix(mcp): bind post-merge moot-lease cleanup to the reconciler capability (Closes #745)' (#746) from fix/issue-745-reconciler-moot-lease-gate into master 2026-07-18 22:04:42 -05:00
jcwalker3 a517655aad Merge branch 'master' into fix/issue-745-reconciler-moot-lease-gate 2026-07-18 21:45:47 -05:00
sysadmin 447595b72b Merge pull request 'fix(mcp): allow create_issue from clean control checkout (Closes #749)' (#750) from fix/issue-749-create-issue-bootstrap into master 2026-07-18 21:38:06 -05:00
jcwalker3 5e0e474350 Merge branch 'master' into fix/issue-749-create-issue-bootstrap 2026-07-18 21:16:23 -05:00
sysadmin 043df0f7df Merge pull request 'fix(mcp): let a sanctioned recovery keep its own owning PR (Closes #755)' (#756) from fix/issue-755-owning-pr-recovery into master 2026-07-18 21:08:34 -05:00
sysadminandClaude Opus 4.8 f858c1d1b2 fix(mcp): let a sanctioned recovery keep its own owning PR (Closes #755)
#753 added the dead-session author-lock recovery assessor, but no sanctioned
recovery could ever reach the lock write. A dead-session lock is by construction
a lock for work that already has an open PR, and the #400 duplicate-work gate
blocked unconditionally on any linked open PR. gitea_lock_issue computed
recovery_sanctioned, then discarded it one gate later:

    open PR #750 already covers issue #749 (fail closed)

Because the recovered lock was never persisted, it stayed non-live, so
_prove_author_ownership_for_pr found no live author lock and
gitea_update_pr_branch_by_merge failed closed too. Both blockers had a single
cause, so only the first one is fixed here.

issue_lock_recovery.owning_pr_recovery_evidence distils a granted assessment
into the minimum evidence the duplicate gate needs: issue, branch, owning PR
number, and head. It returns None unless the outcome is RECOVERY_SANCTIONED and
the assessment's own evidence agrees that local head == remote head == PR head,
so a partial or hand-built assessment cannot authorize anything.

The duplicate gate takes that evidence and re-checks every element against the
live PR list it was handed: exactly one linked open PR, matching number, head
branch, head SHA, and locked branch. Only that exact self-owned PR is exempt.
A different PR, several linked PRs, a different branch or head, or evidence for
another issue all keep failing closed, with a diagnostic naming which element
disagreed. The competing-branch, claim-lease and commit/push/create_pr arms are
untouched, and with no evidence the gate behaves exactly as before.

Ownership proof needed no change: once the recovered lock persists under the
live session pid, _prove_author_ownership_for_pr matches it as before.

Out of scope: the PHASE_COMMIT/PHASE_PUSH/PHASE_CREATE_PR rechecks still block
on a linked open PR for every author, recovered or not. That is pre-existing
#400 behavior, unrelated to lock recovery, and #755 does not cover it.

Tests
- tests/test_issue_755_owning_pr_recovery.py (new, 31 tests) drives the real
  mcp_server.gitea_lock_issue handler, not only the pure assessor: sanctioned
  recovery relocks, persists a live lease with truthful dead_session_recovery
  provenance, and satisfies _prove_author_ownership_for_pr; competing PR,
  multiple linked PRs, mismatched head/branch/worktree, dirty worktree, live
  prior pid, and a fresh claim with no prior lock all stay blocked.
- Neutralizing owning_pr_recovery_evidence regresses all three success tests to
  the exact production error above, so the coverage is load-bearing.
- Focused suites (755, 753, duplicate gates, lock registration, provenance) —
  102 passed.
- Lock/ownership/worktree/handoff suites — 172 passed, 16 subtests.
- Full suite tests/ — 3529 passed, 6 skipped, 2 failed, 365 subtests.
- The same 2 failures reproduce on a pristine detached worktree at
  08f67007c5 (3498 passed, 6 skipped, 2 failed):
  test_issue_702_review_findings_f1_f6 F1 recovery-before-probe and
  test_reconciler_supersession_close org/repo forwarding. Pre-existing.
- git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017BNsk3KUuFchaZyPJsCxjk
2026-07-18 21:48:56 -04:00
sysadmin 08f67007c5 Merge pull request 'fix(mcp): allow author-lock recovery after the owning session exits (Closes #753)' (#754) from fix/issue-753-dead-pid-author-lock-recovery into master 2026-07-18 20:03:26 -05:00
sysadminandClaude Opus 4.8 3edeba4d7f fix(mcp): allow author-lock recovery after the owning session exits (Closes #753)
A durable author issue lock records the PID of the MCP session that took it.
When that process exits, assess_lock_freshness marks the lock stale (live=False)
even while its lease is still within TTL, so every ownership check that needs a
live lock fails closed -- including gitea_update_pr_branch_by_merge.

Re-taking the lock was unreachable for real work. assess_issue_lock_worktree
requires the worktree to be base-equivalent to master/main/dev, and a branch
that already carries commits is ahead of its base by construction. The existing
assess_expired_lock_reclaim affordance does not apply either: it is only
consulted once the lease has expired, so a dead PID under an unexpired lease
never reaches it. assess_own_branch_adoption already speaks of "lock recovery",
but it runs after the base-equivalence gate and so was never reached.

This adds issue_lock_recovery, a pure assessor that grants a narrow waiver only
when every element of durable ownership still matches exactly and the recorded
process is demonstrably dead: same remote/org/repo/issue, same branch (and the
worktree actually on it), same registered worktree, clean worktree, local head
== remote head == open PR head, same claimant identity/profile, no competing
live lock or lease, and no ambiguous branch claims. A malformed or incomplete
lock record can never prove ownership.

The waiver suppresses base-equivalence and nothing else. Cleanliness and every
other precondition still apply, and brand-new issue claims keep the full
requirement. A refused assessment never raises: it withholds the waiver and
lets the pre-existing guard fail closed exactly as before, so recovery can only
ever add permission, never remove a guard. Refusal reasons are appended to the
block message so a caller sees the exact missing evidence.

A completed recovery is recorded on the lock as dead_session_recovery with the
prior and replacement session PIDs, heads, and claimant, so the takeover is
auditable and never looks like an original claim. Rebinding sets the live
session PID, so the recovered lock satisfies verify_lock_for_mutation and the
downstream PR update paths.

Validation:
* new tests/test_issue_753_dead_pid_lock_recovery.py -- 33 passed
* issue-lock, adoption, store, provenance, registration, duplicate-gate,
  worktree, create-issue-guard suites -- 120 passed
* MCP server, commit payloads, handoff ledger, PR ownership, branch cleanup
  suites -- 286 passed
* full suite -- 3498 passed, 6 skipped, 2 failed
* the same 2 failures reproduce identically on pristine master 0425bf9a
  (test_issue_702_review_findings_f1_f6 F1 recovery-before-probe and
  test_reconciler_supersession_close org/repo forwarding), so they are
  pre-existing and unrelated
* git diff --check clean

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01L9jMhtvTjm5EajofqqaR3F
2026-07-18 19:31:55 -04:00
sysadmin 0425bf9a43 Merge pull request 'fix(mcp): derive checks requirement from live branch protection (Closes #751)' (#752) from fix/issue-751-checks-assessor into master 2026-07-18 17:52:54 -05:00
jcwalker3 03c64a3219 Merge branch 'master' into fix/issue-745-reconciler-moot-lease-gate 2026-07-18 17:19:37 -05:00
sysadminandClaude Opus 4.8 eac7afe5cb fix(mcp): derive checks requirement from live branch protection (Closes #751)
`gitea_assess_pr_sync_status` could permanently block a merge-ready PR whose
head commit had no status contexts.

Gitea's combined commit-status endpoint reports `state: pending` both when a
check is executing and when the status-context collection is empty. The
wrapper took that `state` verbatim, and `checks_required` defaulted to True
with no production path ever setting it, so "no CI configured" was
indistinguishable from "CI is running" and waiting could never resolve it.

Root cause spans the whole dataflow, not one call site:

* the wrapper read only the combined `state` and never counted contexts;
  its `checks_status="none"` fallback was unreachable for any truthy state
* `pr_sync_status.assess_pr_sync_status` defaulted `checks_required=True`
* the MCP wrapper exposed no derived `checks_required` and never passed one
* the branch-protection reader fetched the payload carrying
  `enable_status_check` / `status_check_contexts` and discarded both

Changes:

* `pr_sync_status.py`: add `classify_commit_checks` plus explicit
  CHECKS_* classifications (success / failure / pending / none /
  not_required / missing_required / unknown). The combined state is
  recorded for observability but is never evidence that CI is executing.
  Aggregation is fail-closed and newest-wins per context.
* `pr_sync_status.py`: rewrite the merge_now checks gate to consume the
  derived `checks_required`, distinguish the new classifications with
  precise blocker reasons, and fail closed on unrecognized vocabulary.
  The previous gate let any value outside its fixed vocabulary fall
  through to merge_now.
* `gitea_mcp_server.py`: add `_branch_protection_policy` deriving both the
  current-base rule and the status-check requirement from one live read;
  `_branch_protection_requires_current_base` is retained as a thin
  accessor with unchanged semantics. Add `_commit_checks_snapshot` which
  returns the context collection alongside the combined state.
* `gitea_mcp_server.py`: wire the derived `checks_required` into the
  production assessment path and report `checks_evidence`.

`checks_required` is always derived from live evidence and is deliberately
not a caller-supplied input, so no session can declare checks optional
without proof. Live classification also outranks a caller-supplied
`checks_status`, which can no longer mask a real required-check failure.
An unreadable protection policy or status collection stays fail-closed.

Approval, current-head, current-base, mergeability, conflict, role, lease
and merge-authorization gates are unchanged.

Tests: `tests/test_issue_751_checks_assessor.py` (40 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 17:38:54 -04:00
sysadmin e349839fd7 fix(mcp): allow create_issue from clean control checkout (#749)
Provide a narrow phase-scoped bootstrap so gitea_create_issue can run from
a clean canonical control checkout when no issue number—and therefore no
issue-backed worktree—can exist yet. Dirty roots, non-base branches, base
races, foreign workspaces, and all post-creation author mutations remain
fail-closed under the ordinary branches-only guard.

Closes #749.
2026-07-18 16:19:04 -04:00
sysadmin fdab6b6c69 Merge pull request 'feat(mcp): use a 10-minute sliding TTL for reviewer and merger PR leases (Closes #747)' (#748) from feat/issue-747-sliding-lease-ttl into master 2026-07-18 13:16:23 -05:00
sysadminandClaude Opus 4.8 277ec5269d feat(mcp): use a 10-minute sliding TTL for reviewer and merger PR leases (Closes #747)
Reviewer and merger PR leases minted a fixed 120-minute expiry (#407 AC6/AC7)
and derived staleness from two further activity bands: stale at 30 minutes,
reclaimable at 60. A session that died — daemon crash, transport flap, client
restart — therefore kept a PR blocked for an hour before anyone could reclaim
it, and two hours before manual cleanup was sanctioned. #718 records the
resulting deadlock: a merge stalled behind a reviewer lease that had stopped
being live long before it stopped being authoritative.

The flaw was using a long fixed expiry as a proxy for "the owner is probably
still alive" instead of making the owner continuously prove liveness.

Sliding window
--------------
`LEASE_TTL_MINUTES = 10` now governs acquisition, and every write of the lease
marker re-derives `expires_at` from the moment of the write, so each heartbeat
slides the window forward. An actively heartbeating session is never evicted
and has no maximum lifetime; a dead one releases its hold within one TTL.

`LEASE_RENEWAL_MINUTES` is named separately from the acquisition TTL. Renewal
previously had no seam at all: the heartbeat slid the expiry only as a side
effect of re-defaulting the acquisition constant, so the two durations could
not be reasoned about or tuned independently. `format_lease_body` now takes an
explicit `ttl_minutes`, and the heartbeat passes the renewal window rather than
relying on that default.

Merger leases acquire through the same lease-body formatter, so they inherit
the identical window by construction rather than by a parallel constant.

Removal of the reclaim tier
---------------------------
`classify_lease_freshness` no longer returns `reclaimable`. Beyond being an
extra waiting tier, that band is unreachable under a sliding TTL: a heartbeat
stamps `last_activity` and `expires_at` together, so a lease idle for a full
TTL is necessarily already expired. Expiry is now the only takeover gate.

No reclaim path is lost. `find_active_reviewer_lease` already ignores expired
markers, so an expired foreign lease never gated acquisition; and the
`foreign_expired` classification carries the same
`NEXT_ACTION_RELEASE_EXPIRED_LEASE` the retired `foreign_reclaimable` did. The
two updated tests in `test_reviewer_pr_lease.py` assert exactly that: the
classification label changes, the sanctioned next action does not.

`STALE_WARNING_MINUTES` drops to 5 — half the window — so the warning still
fires while the owner can heartbeat and recover.

Diagnostics
-----------
Adds `lease_seconds_remaining`, and the heartbeat tool now returns
`ttl_minutes`, `expires_at`, and `seconds_remaining`, so an operator can
distinguish "held and live" from "held and dying" instead of only seeing that
a lease exists. All additions are additive; no existing key changed.

Also removes `pr_work_lease.DEFAULT_REVIEWER_LEASE_TTL_MINUTES`, a duplicate of
the reviewer TTL with no readers anywhere in the tree, which could only drift.

Legacy markers minted under the old 120-minute TTL still parse and are judged
against their own recorded `expires_at`, so no lease is retroactively expired
by this change.

Full suite: 3425 passed, 6 skipped, 2 failed. Both failures
(`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`)
reproduce identically on clean master b05075fd25 and are pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QxXHZ7rqXtLgTusngaWgKZ
2026-07-18 13:55:46 -04:00
sysadminandClaude Opus 4.8 b00e09a781 fix(mcp): bind post-merge moot-lease cleanup to the reconciler capability (Closes #745)
gitea_cleanup_post_merge_moot_lease (#515) posts a terminal `phase: released`
lease marker — a durable mutation of the PR lease ledger — but had no entry in
the canonical task-capability map and no role binding. Entry gated on
gitea.read, apply gated only on gitea.pr.comment, so any profile holding the
comment permission (author, reviewer, merger) reached the mutation path, while
the reconciler could not satisfy the operator-required resolve-exact-task ->
mutation sequence because no cleanup task was resolvable at all. The apply path
also called verify_preflight_purity(remote) with no task/org/repo, skipping the
resolved-task, canonical-root and explicit-target checks its siblings perform,
and passed request org/repo straight into _resolve.

Capability map and router:

- Map `cleanup_post_merge_moot_lease` and the tool-name alias
  `gitea_cleanup_post_merge_moot_lease` to gitea.pr.comment + reconciler.
  Both names carry an identical contract; unknown names keep failing closed on
  the map's KeyError.
- Add both to role_session_router RECONCILER_TASKS and TASK_REQUIRED_ROLE so
  the map and the router cannot disagree (the #723 defect-A class).

Tool enforcement (apply path only):

- Require the session to have resolved exactly the cleanup task; resolving a
  different task, including a sibling reconciler task, does not authorize it.
- Require the reconciler role, checked independently of the permission gate.
- Require gitea.pr.comment.
- Validate explicit org/repo against the canonical repository identity derived
  from the session binding, so request parameters can never redirect the
  mutation, and forward worktree_path/task/org/repo to verify_preflight_purity
  for canonical-root, workspace and anti-stomp binding (#733/#739).
- Require matching dry-run evidence proving lease_moot and cleanup_allowed for
  the same PR, lease session, candidate head and lease marker id, with optional
  caller expectations checked against the live lease.

New post_merge_moot_lease_gate module holds the pure authorization logic and an
append-only dry-run ledger: entries are only ever appended, lookup is
newest-wins, and dry_run_history hands out copies. Live, non-moot, superseded,
mismatched, malformed and foreign-repository leases all fail closed;
already-terminal cleanup stays idempotent.

The read-only apply=false assessment deliberately stays reachable under
gitea.read with no role gate, matching gitea_cleanup_stale_review_decision_lock
and gitea_cleanup_obsolete_reviewer_comment_lease, so an operator can diagnose a
stuck lease from any attached namespace. This choice is documented in the map,
the tool docstring and docs/gitea-execution-profiles.md, and is directly tested.

_delete_branch_repository_binding_block is generalized into
_repository_binding_block(required_permission=...) and retained as a thin
delete-path alias so #733/#739 coverage keeps exercising its permission label.

Tests: new tests/test_issue_745_moot_lease_reconciler_gate.py (39 tests, 35
subtests) covers the map/router contract, alias parity, unknown-name rejection,
role and task gates, dry-run/apply sequencing, superseded and malformed leases,
repository binding, ledger append-only behavior, idempotency, and asserts no
production PR/session/marker is referenced. tests/test_post_merge_moot_lease.py
is updated from the permission-only model to reconciler + dry-run evidence, with
a new negative test pinning that a merger can no longer apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:59:51 -04:00
sysadminandClaude Opus 4.8 324b4b3e93 feat: make master-parity live-remote aware so a stale daemon fails closed (Closes #610)
Master-parity previously compared only the daemon's startup commit against the
local on-disk HEAD. When the checkout was not pulled, parity reported green even
though the live remote master had advanced, so a stale daemon could claim a
mutation-safe result while running outdated capability gates (observed during
PR #592 recovery, where the resolver correctly required restart but parity said
in_parity=true).

Changes:
- master_parity_gate.assess_master_parity() gains an optional live_remote_head
  and reports the three commits distinctly (daemon_start_head, local_head,
  live_remote_head) plus live_known / live_stale / mutation_safe. A result is
  mutation_safe only when daemon, local checkout, and live remote all agree.
- parity_block_reasons() now blocks mutations on live-staleness too; read-only
  operations remain unblocked (non-goal: never block diagnostics offline).
- New parity_resolver_disagreement(): typed fail-closed blocker naming the
  capability resolver as authoritative when it requires restart but parity
  looks locally green.
- New read_remote_master_head(): best-effort `git ls-remote` for the live
  target, cached with a 60s TTL (bounded offline latency, no network probe per
  gate call); env override GITEA_TEST_LIVE_REMOTE_HEAD keeps tests hermetic.
- Server: _current_master_parity() reads the live remote head;
  gitea_assess_master_parity and runtime_context surface the distinguished
  SHAs, mutation_safe, and resolver-authoritative guidance.

Tests: 13 new cases (live-remote parity, live-stale blocking + typed blocker,
remote-head reader + TTL cache, server wiring). Full suite: 2423 passed; the 8
remaining failures (test_config TestAuthIntegration, test_credentials
TestGetCredentials) are baseline-proven keychain/env failures identical on the
unmodified base.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-09 18:52:59 -04:00
92 changed files with 26589 additions and 431 deletions
+150
View File
@@ -0,0 +1,150 @@
"""Canonical dependency parsing and live-state resolution for the allocator (#758).
The work allocator previously inferred dependency state from two lowercase
substrings (``"blocked on #"`` / ``"downstream of #"``). The repository's
canonical declaration form is a ``Depends:`` field inside the issue body's
linkage line, for example::
* Parent: #631 · Depends: #633, #634 · Related: #630, #434
That form matched neither substring, so dependency-blocked issues were emitted
as eligible candidates. This module replaces substring inference with:
1. structured parsing of ``Depends:`` declarations into issue references, and
2. resolution of each reference against **live issue state**, never body text.
Both halves fail closed: a reference whose state cannot be established makes
the owning candidate ineligible rather than assignable.
No issue number is special-cased here (#758 AC4/AC14); the parser is driven
entirely by the declaration syntax.
"""
from __future__ import annotations
import re
from typing import Callable, Iterable
# Live issue states, as reported by Gitea.
DEP_STATE_OPEN = "open"
DEP_STATE_CLOSED = "closed"
# "Depends:" / "Depends on:" introduces the declaration. "Dependencies" does
# not match: after "depend" it continues with "e", not "s".
_DEPENDS_KEYWORD = re.compile(r"depends(?:\s+on)?\s*:?\s*", re.IGNORECASE)
# Immediately after the keyword, consume only the contiguous run of issue
# references. Anchoring the run this way means the declaration ends naturally
# at the next separator ("·", newline) or sibling field ("Related:"), without
# needing to enumerate separators.
_DEP_RUN = re.compile(
r"\s*(#\d+(?:\s*(?:,|and|&)\s*#\d+)*)",
re.IGNORECASE,
)
# Legacy marker retained so previously-recognized bodies keep working.
_LEGACY_BLOCKED = re.compile(r"blocked\s+on\s+#(\d+)", re.IGNORECASE)
_ISSUE_REF = re.compile(r"#(\d+)")
def parse_dependency_refs(body: str | None) -> tuple[int, ...]:
"""Extract declared dependency issue numbers from an issue *body*.
Recognizes the canonical ``Depends: #N, #N`` field (including the
``Depends on`` spelling) plus the legacy ``blocked on #N`` marker.
Returns references in first-seen order with duplicates removed. Malformed
or absent declarations yield an empty tuple rather than raising.
"""
if not body:
return ()
refs: list[int] = []
def _add(value: str) -> None:
number = int(value)
if number > 0 and number not in refs:
refs.append(number)
for match in _DEPENDS_KEYWORD.finditer(body):
run = _DEP_RUN.match(body, match.end())
if not run:
continue
for ref in _ISSUE_REF.findall(run.group(1)):
_add(ref)
for ref in _LEGACY_BLOCKED.findall(body):
_add(ref)
return tuple(refs)
def resolve_dependency_state(
refs: Iterable[int],
state_lookup: Callable[[int], str | None],
*,
subject: str = "candidate",
) -> dict:
"""Resolve declared *refs* against live issue state.
*state_lookup* maps an issue number to its live state string, or to
``None`` when that evidence could not be obtained. A reference is:
* **met** when live state is ``closed``;
* **unmet** when live state is any other live value (``open``, etc.);
* **unavailable** when state is ``None`` or the lookup raises.
Unmet *and* unavailable both mark the candidate ineligible (#758 AC6/AC7):
allocation must never assume a dependency is satisfied.
"""
unmet: list[int] = []
unavailable: list[int] = []
met: list[int] = []
for ref in refs:
try:
number = int(ref)
except (TypeError, ValueError):
continue
try:
state = state_lookup(number)
except Exception: # noqa: BLE001 — unavailable evidence fails closed
state = None
normalized = (str(state).strip().lower() if state is not None else "") or None
if normalized is None:
unavailable.append(number)
elif normalized == DEP_STATE_CLOSED:
met.append(number)
else:
unmet.append(number)
reason: str | None = None
if unmet and unavailable:
reason = (
f"{subject} has unresolved dependencies "
f"{_fmt(unmet)} and unverifiable dependencies {_fmt(unavailable)} "
"(fail closed)"
)
elif unmet:
reason = (
f"{subject} depends on unresolved issue(s) {_fmt(unmet)}; "
"they are not closed"
)
elif unavailable:
reason = (
f"{subject} dependency evidence unavailable for {_fmt(unavailable)} "
"(fail closed)"
)
return {
"refs": tuple(int(x) for x in refs),
"met": tuple(met),
"unmet": tuple(unmet),
"unavailable": tuple(unavailable),
"dependency_unmet": bool(unmet or unavailable),
"reason": reason,
}
def _fmt(numbers: Iterable[int]) -> str:
return ", ".join(f"#{n}" for n in numbers)
+502 -11
View File
@@ -18,10 +18,12 @@ after they exist as normal issues; this module never assigns incidents.
from __future__ import annotations
import hashlib
import json
import os
import uuid
from dataclasses import dataclass, field
from typing import Any, Sequence
from typing import Any, Mapping, Sequence
from control_plane_db import (
ControlPlaneDB,
@@ -40,6 +42,31 @@ OUTCOME_NEEDS_CONTROLLER = "needs_controller"
OUTCOME_NO_SAFE = "no_safe_work"
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
# #765: ownership could not be established for every remaining candidate.
OUTCOME_OWNERSHIP_DEFECT = "allocator_ownership_defect"
# #776: excluded issue still carries a live same-owner lease — resume or release.
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE = "blocked_by_excluded_own_lease"
# #776: dry-run/apply candidate-set fingerprint mismatch (CAS drift).
OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
# #765 skip reason code for work already claimed by a different controller.
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
# #776: controller-supplied pre-rank exclusion.
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
# Ownership verdicts for a live claim on a candidate (#765).
OWNERSHIP_OWN = "own"
OWNERSHIP_FOREIGN = "foreign"
OWNERSHIP_UNKNOWN = "unknown"
# Human-readable statement of how a winner is chosen (#758 AC10). Reported
# alongside allocator results so the flat status:ready tier and its
# oldest-number tie-break are explicit rather than incidental.
SELECTION_POLICY = (
"rank complete inventory by (priority desc, PRs before issues, "
"number asc); status:ready issues share priority 20, so the oldest "
"eligible number wins ties; result limits never affect selection"
)
ROLE_AUTHOR = "author"
ROLE_REVIEWER = "reviewer"
@@ -135,9 +162,76 @@ class SkipRecord:
kind: str
number: int
reason: str
reason_code: str | None = None
def as_dict(self) -> dict[str, Any]:
return {"kind": self.kind, "number": self.number, "reason": self.reason}
return {
"kind": self.kind,
"number": self.number,
"reason": self.reason,
"reason_code": self.reason_code,
}
CONTROLLER_INSTANCE_ENV = "GITEA_CONTROLLER_INSTANCE_ID"
def resolve_controller_instance_id(
env: Mapping[str, str] | None = None,
) -> str | None:
"""Return this controller's stable identity, or ``None`` if undeclared.
Deliberately has no derived fallback. The obvious candidates are unsafe:
``session_id`` is regenerated per invocation, and the MCP process pid is
shared by every controller attached to the same daemon — two independent
controllers really do report the same pid and profile. Guessing from either
would let one controller adopt another's lease, which is the failure #765
exists to prevent. When this returns ``None``, live claims are treated as
unidentified: they are excluded from selection and reported as ownership
defects rather than adopted.
"""
source = env if env is not None else os.environ
return (source.get(CONTROLLER_INSTANCE_ENV) or "").strip() or None
def classify_claim_ownership(
claim: dict[str, Any] | None,
*,
session_id: str | None,
controller_instance_id: str | None,
) -> str | None:
"""Classify a live claim as own / foreign / unknown ownership (#765).
Returns ``None`` when the candidate carries no live claim.
Session ids are regenerated per allocator invocation, so they only prove
ownership positively (an exact match is certainly this session). The
durable signal is ``controller_instance_id``. When either side lacks one,
ownership is *unknown*: the allocator must not assume that a lease sharing
the same profile belongs to this controller, so unknown is treated as
not-ours for selection purposes and reported as an ownership defect.
"""
if not claim:
return None
claim_session = str(claim.get("session_id") or "").strip()
claim_instance = str(claim.get("controller_instance_id") or "").strip()
own_session = str(session_id or "").strip()
own_instance = str(controller_instance_id or "").strip()
if claim_session and own_session and claim_session == own_session:
return OWNERSHIP_OWN
if claim_instance and own_instance:
return (
OWNERSHIP_OWN if claim_instance == own_instance else OWNERSHIP_FOREIGN
)
if not claim_instance and not own_instance:
# Neither side declares a controller identity. The session ids differ
# (an exact match returned OWN above), so this is simply someone
# else's lease: foreign, and we wait rather than adopt.
return OWNERSHIP_FOREIGN
# Exactly one side is identified, so the two cannot be compared: this may
# or may not be our own task under a different session id. Never guess.
return OWNERSHIP_UNKNOWN
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
@@ -194,8 +288,16 @@ def classify_skip(
*,
role: str,
terminal_pr: int | None,
claim_ownership: str | None = None,
) -> str | None:
"""Return skip reason, or None if candidate is selectable for *role*."""
"""Return skip reason, or None if candidate is selectable for *role*.
*claim_ownership* (#765) is the verdict from
:func:`classify_claim_ownership` for this candidate's live claim. Foreign
and unknown claims are excluded so one session's in-progress task can never
blockade the queue for a different controller; ``own`` stays selectable so
a controller can resume its own work.
"""
if c.state in ("merged", "closed"):
return f"{c.kind}#{c.number} is {c.state}; never assign"
if c.blocked or "status:blocked" in c.labels:
@@ -205,6 +307,16 @@ def classify_skip(
c.dependency_reason
or f"{c.kind}#{c.number} has unmet dependencies"
)
if claim_ownership in (OWNERSHIP_FOREIGN, OWNERSHIP_UNKNOWN):
detail = (
"owned by another controller instance"
if claim_ownership == OWNERSHIP_FOREIGN
else "owner could not be identified; never adopt on a guess"
)
return (
f"{c.kind}#{c.number} {SKIP_CLAIMED_BY_OTHER_SESSION}: "
f"active lease {detail}"
)
if c.already_claimed_elsewhere:
return f"{c.kind}#{c.number} already claimed elsewhere"
if c.kind == "pr" and not (c.head_sha or "").strip():
@@ -242,13 +354,136 @@ def classify_skip(
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
"""Higher priority first; then lower number (older issues) for stability."""
"""Rank candidates deterministically (#758 AC10).
Ordering key, in precedence order:
1. ``priority`` descending — the loader scores ``status:ready`` issues at
20 and everything else at 1, so the ready queue ties at a single value
by design;
2. PRs before issues — in-flight review work drains before new authoring;
3. ``number`` ascending — oldest first, which is what actually breaks the
flat ``status:ready`` tie.
Because the ready tier is intentionally flat, rule 3 decides most real
selections. That is only safe when ranking sees the *complete* candidate
inventory: truncating before this call silently redefines "oldest" as
"oldest among whatever survived the slice", which is the defect #758
fixed. Callers must rank everything and bound reporting afterwards.
"""
return sorted(
candidates,
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
)
def _require_strict_int(value: Any, *, field: str) -> int:
"""Parse an issue number; reject bools and non-integers (#776)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"{field} must be an integer (booleans and non-integers rejected; "
f"got {type(value).__name__})"
)
return int(value)
def normalize_exclude_issue_numbers(
exclude_issue_numbers: Any = None,
) -> list[int]:
"""Normalize controller-supplied exclusions to a sorted unique int list (#776).
``None`` / omitted → empty list (existing behavior). Accepts a list/tuple of
integers. Rejects scalars, bools-as-ints, nested structures, and strings.
"""
if exclude_issue_numbers is None:
return []
if isinstance(exclude_issue_numbers, (str, bytes)) or not isinstance(
exclude_issue_numbers, (list, tuple)
):
raise ValueError(
"exclude_issue_numbers must be a list of integers "
f"(got {type(exclude_issue_numbers).__name__})"
)
out: list[int] = []
seen: set[int] = set()
for idx, raw in enumerate(exclude_issue_numbers):
num = _require_strict_int(raw, field=f"exclude_issue_numbers[{idx}]")
if num not in seen:
seen.add(num)
out.append(num)
return sorted(out)
def candidate_set_fingerprint(
candidates: Sequence[WorkCandidate],
*,
exclude_issue_numbers: Sequence[int] | None = None,
) -> str:
"""Stable CAS fingerprint of normalized candidate set + exclusions (#776 AC4)."""
payload = {
"candidates": sorted(
({"kind": c.kind, "number": int(c.number)} for c in candidates),
key=lambda x: (x["kind"], x["number"]),
),
"exclude_issue_numbers": list(
normalize_exclude_issue_numbers(exclude_issue_numbers)
),
}
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def normalize_candidates_payload(raw: Any) -> list[WorkCandidate]:
"""Decode MCP ``candidates_json`` from list or JSON string (#776 AC3).
Accepts:
* an already-decoded ``list`` of candidate dicts (native MCP transport);
* a valid JSON string that decodes to such a list (backward compatible).
Rejects malformed JSON, scalars, non-list containers, invalid records,
booleans-as-integers, and unsupported types with fail-closed ``ValueError``.
"""
if raw is None:
raise ValueError("candidates_json is empty")
if isinstance(raw, (bytes, bytearray)):
try:
raw = raw.decode("utf-8")
except Exception as exc: # noqa: BLE001
raise ValueError(
f"candidates_json bytes are not valid utf-8: {exc}"
) from exc
if isinstance(raw, str):
text = raw.strip()
if not text:
raise ValueError("candidates_json string is empty")
try:
decoded = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(
f"malformed candidates_json JSON: {exc.msg} at pos {exc.pos}"
) from exc
raw = decoded
if not isinstance(raw, list):
raise ValueError(
"candidates_json must be a JSON list (or already-decoded list); "
f"got {type(raw).__name__}"
)
candidates: list[WorkCandidate] = []
for idx, item in enumerate(raw):
if not isinstance(item, dict):
raise ValueError(
f"invalid candidate record at index {idx}: expected object, "
f"got {type(item).__name__}"
)
try:
candidates.append(candidate_from_dict(item))
except (KeyError, TypeError, ValueError, InvalidWorkKindError) as exc:
raise ValueError(
f"invalid candidate record at index {idx}: {exc}"
) from exc
return candidates
def allocate_next_work(
db: ControlPlaneDB,
*,
@@ -262,12 +497,22 @@ def allocate_next_work(
profile_name: str | None = None,
username: str | None = None,
lease_ttl_seconds: int | None = None,
controller_instance_id: str | None = None,
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
exclude_issue_numbers: Sequence[int] | None = None,
expected_candidate_set_fingerprint: str | None = None,
) -> dict[str, Any]:
"""Select and optionally reserve the next work unit via control-plane DB.
*apply=False* (default): dry-run selection only — no lease/assignment.
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
empty preserves prior behavior.
*expected_candidate_set_fingerprint* (#776 AC4): when set on apply, rejects
material candidate-set drift vs a prior dry-run.
Never uses file locks or comment-only leases as the assignment source.
"""
if db is None:
@@ -303,6 +548,7 @@ def allocate_next_work(
role=role_norm,
profile=profile_name,
pid=os.getpid(),
controller_instance_id=controller_instance_id,
)
except Exception as exc: # noqa: BLE001 — surface structured
return {
@@ -344,30 +590,238 @@ def allocate_next_work(
}
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
# #765: live claims exclude work owned by a *different* controller before
# ranking, so one session's in-progress task cannot blockade the queue.
# #776 AC5: load live claims in this call path immediately before selection
# (and before apply reserve) so ownership is never stale within the
# allocation attempt. Test callers may inject *claims* explicitly.
if claims is None:
try:
claims = db.list_active_claims(remote=remote, org=org, repo=repo)
except Exception as exc: # noqa: BLE001
return {
"success": False,
"outcome": OUTCOME_NO_SAFE,
"reasons": [
f"active claim lookup failed: {exc} (fail closed, #765)"
],
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
}
try:
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
except ValueError as exc:
return {
"success": False,
"outcome": OUTCOME_NO_SAFE,
"apply": bool(apply),
"reasons": [
f"invalid exclude_issue_numbers: {exc} (fail closed, #776)"
],
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
}
exclude_set = set(exclude_nums)
cas_fp = candidate_set_fingerprint(
candidates, exclude_issue_numbers=exclude_nums
)
expected_fp = (expected_candidate_set_fingerprint or "").strip() or None
if expected_fp and expected_fp != cas_fp:
return {
"success": False,
"outcome": OUTCOME_CANDIDATE_SET_DRIFT,
"apply": bool(apply),
"reasons": [
"candidate-set fingerprint drift: apply rejected rather than "
"silently leasing a different candidate (#776 AC4)"
],
"candidate_set_fingerprint": cas_fp,
"expected_candidate_set_fingerprint": expected_fp,
"exclude_issue_numbers": list(exclude_nums),
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
}
skipped: list[SkipRecord] = []
ordered = sort_candidates(list(candidates))
claims_excluded: list[dict[str, Any]] = []
ownership_defects: list[dict[str, Any]] = []
controller_excluded: list[dict[str, Any]] = []
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
rankable: list[WorkCandidate] = []
for c in candidates:
if int(c.number) in exclude_set:
reason = (
f"{c.kind}#{c.number} {SKIP_EXCLUDED_BY_CONTROLLER}: "
"controller pre-rank exclusion"
)
skipped.append(
SkipRecord(
c.kind,
c.number,
reason,
SKIP_EXCLUDED_BY_CONTROLLER,
)
)
controller_excluded.append(
{
"kind": c.kind,
"number": c.number,
"reason_code": SKIP_EXCLUDED_BY_CONTROLLER,
}
)
# #776 AC5: same-owner live lease on an excluded issue is a
# structured resume/release blocker, never a silent strand.
claim = claims.get((c.kind, int(c.number))) if claims else None
ownership = classify_claim_ownership(
claim,
session_id=session_id,
controller_instance_id=controller_instance_id,
)
if ownership == OWNERSHIP_OWN and claim:
return {
"success": True,
"outcome": OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
"apply": bool(apply),
"role": role_norm,
"profile_name": profile_name,
"username": username,
"session_id": session_id,
"remote": remote,
"org": org,
"repo": repo,
"selected": None,
"expected_role_next": None,
"reasons": [
f"{c.kind}#{c.number} is excluded_by_controller but "
"carries a live same-owner lease; resume or release "
"that lease before allocating other work (#776 AC5)"
],
"skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr,
"assignment": None,
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
"controller_instance_id": controller_instance_id,
"claims_excluded": list(claims_excluded),
"ownership_defects": list(ownership_defects),
"controller_excluded": list(controller_excluded),
"exclude_issue_numbers": list(exclude_nums),
"candidate_set_fingerprint": cas_fp,
"blocked_lease": {
"kind": c.kind,
"number": c.number,
"lease_id": claim.get("lease_id"),
"owner_session_id": claim.get("session_id"),
"owner_controller_instance_id": claim.get(
"controller_instance_id"
),
"expires_at": claim.get("expires_at"),
"safe_next_action": (
"resume the same-owner lease or release it, then "
"re-run allocation without stranding the excluded "
"issue"
),
},
}
continue
rankable.append(c)
ordered = sort_candidates(rankable)
selected: WorkCandidate | None = None
for c in ordered:
reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
claim = claims.get((c.kind, int(c.number))) if claims else None
ownership = classify_claim_ownership(
claim,
session_id=session_id,
controller_instance_id=controller_instance_id,
)
reason = classify_skip(
c,
role=role_norm,
terminal_pr=terminal_pr,
claim_ownership=ownership,
)
if reason:
skipped.append(SkipRecord(c.kind, c.number, reason))
is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason
skipped.append(
SkipRecord(
c.kind,
c.number,
reason,
SKIP_CLAIMED_BY_OTHER_SESSION if is_claim_skip else None,
)
)
if is_claim_skip and claim:
record = {
"kind": c.kind,
"number": c.number,
"ownership": ownership,
"lease_id": claim.get("lease_id"),
"owner_session_id": claim.get("session_id"),
"owner_controller_instance_id": claim.get(
"controller_instance_id"
),
"expires_at": claim.get("expires_at"),
}
claims_excluded.append(record)
if ownership == OWNERSHIP_UNKNOWN:
ownership_defects.append(record)
continue
selected = c
break
if selected is None:
# If terminal lock blocks all review work, surface that explicitly.
owner_session_id: str | None = None
if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
outcome = OUTCOME_BLOCKED_TERMINAL
reasons = [
f"no safe work for role '{role_norm}': active terminal-review "
f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
]
elif ownership_defects:
# #765: every remaining candidate is claimed and at least one owner
# could not be identified. Report the defect; never adopt.
outcome = OUTCOME_OWNERSHIP_DEFECT
reasons = [
f"no safe assignable work for role '{role_norm}': "
f"{len(ownership_defects)} candidate(s) carry an active lease "
"whose controller ownership could not be established. Record a "
"controller_instance_id on those sessions; the allocator will "
"not assume a shared profile means shared ownership (#765)."
]
elif claims_excluded:
outcome = OUTCOME_WAIT
reasons = [
f"no unclaimed work for role '{role_norm}': "
f"{len(claims_excluded)} candidate(s) are actively claimed by "
"another controller. Waiting; their leases are not adopted (#765)."
]
# Preserve the pre-#765 wait contract: name the blocking owner.
owner_session_id = claims_excluded[0].get("owner_session_id")
elif controller_excluded and not ordered:
# #776 AC7: every candidate was controller-excluded → wait / no lease.
outcome = OUTCOME_WAIT
reasons = [
f"no assignable work for role '{role_norm}': all "
f"{len(controller_excluded)} candidate(s) were removed by "
f"{SKIP_EXCLUDED_BY_CONTROLLER} before ranking; no assignment "
"or lease created (#776 AC7)"
]
else:
outcome = OUTCOME_NO_SAFE
reasons = [
f"no safe assignable work for role '{role_norm}' "
f"among {len(ordered)} candidates"
f"among {len(ordered)} rankable candidates "
f"({len(controller_excluded)} controller-excluded)"
]
return {
"success": True,
@@ -389,6 +843,13 @@ def allocate_next_work(
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
"controller_instance_id": controller_instance_id,
"claims_excluded": list(claims_excluded),
"ownership_defects": list(ownership_defects),
"controller_excluded": list(controller_excluded),
"exclude_issue_numbers": list(exclude_nums),
"candidate_set_fingerprint": cas_fp,
"owner_session_id": owner_session_id,
"downstream_note": (
"#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents"
@@ -435,6 +896,12 @@ def allocate_next_work(
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
"controller_instance_id": controller_instance_id,
"claims_excluded": list(claims_excluded),
"ownership_defects": list(ownership_defects),
"controller_excluded": list(controller_excluded),
"exclude_issue_numbers": list(exclude_nums),
"candidate_set_fingerprint": cas_fp,
"downstream_note": (
"#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents"
@@ -558,6 +1025,12 @@ def allocate_next_work(
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
"controller_instance_id": controller_instance_id,
"claims_excluded": list(claims_excluded),
"ownership_defects": list(ownership_defects),
"controller_excluded": list(controller_excluded),
"exclude_issue_numbers": list(exclude_nums),
"candidate_set_fingerprint": cas_fp,
"downstream_note": (
"#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents"
@@ -589,14 +1062,32 @@ def _next_command(role: str, c: WorkCandidate) -> str:
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
"""Build a WorkCandidate from a plain dict (tests / MCP inventory).
#776: reject booleans-as-integers and non-int numbers fail-closed.
"""
if "number" not in data:
raise KeyError("number")
number = _require_strict_int(data["number"], field="number")
priority_raw = data.get("priority") or 0
if isinstance(priority_raw, bool) or not isinstance(priority_raw, (int, float)):
# Allow numeric strings only for priority? Keep strict for bools.
if isinstance(priority_raw, str) and priority_raw.strip().lstrip("-").isdigit():
priority = int(priority_raw)
else:
raise ValueError(
f"priority must be numeric (booleans rejected; "
f"got {type(priority_raw).__name__})"
)
else:
priority = int(priority_raw)
return WorkCandidate(
kind=str(data.get("kind") or "issue"),
number=int(data["number"]),
number=number,
state=str(data.get("state") or "open"),
labels=tuple(data.get("labels") or ()),
title=str(data.get("title") or ""),
priority=int(data.get("priority") or 0),
priority=priority,
head_sha=data.get("head_sha"),
request_changes_current_head=bool(data.get("request_changes_current_head")),
approval_on_current_head=bool(data.get("approval_on_current_head")),
+21 -2
View File
@@ -33,6 +33,7 @@ from __future__ import annotations
from typing import Any
import author_mutation_worktree
import create_issue_bootstrap
import master_parity_gate
import remote_repo_guard
import root_checkout_guard
@@ -76,6 +77,7 @@ MUTATION_TASKS = frozenset({
"create_issue",
"comment_issue",
"close_issue",
"edit_issue",
"mark_issue",
"lock_issue",
"set_issue_labels",
@@ -354,6 +356,7 @@ def assess_anti_stomp_preflight(
remote_master_sha: str | None = None,
check_root_checkout: bool = True,
check_worktree: bool = True,
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
# stale runtime (master parity)
startup_head: str | None = None,
current_code_head: str | None = None,
@@ -584,12 +587,28 @@ def assess_anti_stomp_preflight(
project_root=project_root,
current_branch=current_branch,
)
# #757: the #274 guard consults the server-derived create_issue
# bootstrap before blocking the canonical control checkout. Route this
# guard's decision through the *same* predicate on the *same*
# assessment so the two cannot disagree about identical evidence.
# Only the wrong-worktree verdict is waived; every other check in this
# assessment (root checkout, repo, role, stale runtime, lease, ...) is
# evaluated independently and still applies.
bootstrap_waived = wt.get("block") and (
create_issue_bootstrap.bootstrap_permits_control_checkout(
create_issue_bootstrap_assessment,
task=task_name,
workspace_path=workspace_path,
canonical_repo_root=project_root,
)
)
checks["worktree"] = {
"block": bool(wt.get("block")),
"block": bool(wt.get("block")) and not bootstrap_waived,
"reasons": list(wt.get("reasons") or []),
"under_branches": wt.get("under_branches"),
"create_issue_bootstrap_waived": bool(bootstrap_waived),
}
if wt.get("block"):
if wt.get("block") and not bootstrap_waived:
blockers.append(
_blocker(
BLOCKER_WRONG_WORKTREE,
+544 -3
View File
@@ -1,7 +1,15 @@
"""Branches-only author mutation worktree guard (#274).
"""Branches-only author mutation worktree guard (#274) with durable resolution (#618).
Author/coder mutations must run from a session-owned worktree under the
project's ``branches/`` directory, never from the stable control checkout.
#618 durable resolution:
- Prefer an explicit validated ``worktree_path`` argument.
- Else derive the workspace from the active author issue lock's worktree.
- Env bindings (``GITEA_ACTIVE_WORKTREE`` / ``GITEA_AUTHOR_WORKTREE``) may bind
when present and valid.
- Author mutations never silently fall back to the control checkout or master.
- Missing configured bindings fail closed with a clear operator recovery action.
"""
from __future__ import annotations
@@ -15,6 +23,18 @@ AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
# via namespace_workspace_binding (#510).
BOUND_WORKTREE_MISSING = "bound_worktree_missing"
BOUND_WORKTREE_MISSING_MESSAGE = (
"bound worktree missing; operator must recreate or repoint the worktree "
"and reconnect"
)
OPERATOR_RECOVERY_RECREATE_REPOINT = (
"Recreate the worktree under branches/ (scripts/worktree-start or "
"git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE "
"to that path (or pass worktree_path on mutation tools), keep the control "
"checkout clean on master, then reconnect the author MCP session and re-run."
)
def _normalize_path(path: str) -> str:
return (path or "").replace("\\", "/").rstrip("/")
@@ -45,7 +65,11 @@ def resolve_mutation_workspace(
active_worktree_env: str | None = None,
author_worktree_env: str | None = None,
) -> str:
"""Resolve the workspace path inspected before author mutations."""
"""Resolve the workspace path inspected before author mutations.
Legacy helper: returns the first non-empty candidate path. Prefer
:func:`resolve_durable_author_worktree` for mutation guards (#618).
"""
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
text = (candidate or "").strip()
if text:
@@ -231,4 +255,521 @@ def format_author_mutation_worktree_error(assessment: dict) -> str:
f"Branches-only mutation guard (#274): {reasons}. "
f"project root: {root}; workspace: {workspace}. "
"Create a session-owned worktree under branches/ before mutating."
)
)
# ---------------------------------------------------------------------------
# #618 durable author worktree resolution
# ---------------------------------------------------------------------------
def _abs_real(path: str) -> str:
return os.path.realpath(os.path.abspath((path or "").strip()))
def assess_path_traversal_safety(
*,
path: str,
canonical_repo_root: str,
) -> dict:
"""Fail closed on traversal/symlink escapes outside the target repository.
Uses ``realpath`` so intermediate symlinks cannot walk outside
``canonical_repo_root``. Author mutation workspaces must also land under
``branches/`` of that root (enforced separately by the branches-only guard).
"""
reasons: list[str] = []
raw = (path or "").strip()
if not raw:
return {
"proven": False,
"block": True,
"reasons": ["worktree path is empty (fail closed)"],
"workspace_path": None,
"canonical_repo_root": os.path.realpath(canonical_repo_root),
}
if "\x00" in raw:
return {
"proven": False,
"block": True,
"reasons": ["worktree path contains a null byte (fail closed)"],
"workspace_path": raw,
"canonical_repo_root": os.path.realpath(canonical_repo_root),
}
root = os.path.realpath(canonical_repo_root)
# Resolve without requiring existence first: abspath then realpath of parents.
abs_path = os.path.abspath(raw)
try:
real = os.path.realpath(abs_path)
except OSError as exc:
return {
"proven": False,
"block": True,
"reasons": [f"worktree path could not be resolved safely: {exc}"],
"workspace_path": abs_path,
"canonical_repo_root": root,
}
root_norm = _normalize_path(root)
real_norm = _normalize_path(real)
if real_norm != root_norm and not real_norm.startswith(f"{root_norm}/"):
reasons.append(
f"worktree path '{real}' escapes canonical repository root '{root}' "
"(traversal/symlink safety, fail closed)"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"workspace_path": real,
"canonical_repo_root": root,
}
def list_git_worktree_paths(canonical_repo_root: str) -> list[str]:
"""Return realpaths registered in ``git worktree list --porcelain``."""
root = os.path.realpath(canonical_repo_root)
try:
res = subprocess.run(
["git", "-C", root, "worktree", "list", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
except Exception:
return []
if res.returncode != 0:
return []
paths: list[str] = []
for line in (res.stdout or "").splitlines():
if line.startswith("worktree "):
raw = line[len("worktree ") :].strip()
if raw:
paths.append(os.path.realpath(raw))
return paths
def path_in_git_worktree_list(path: str, canonical_repo_root: str) -> bool | None:
"""True/False when inventory is available; None when git inventory fails.
An empty inventory with a working git root is treated as inconclusive
(``None``) so unit tests and partial sandboxes are not false-negative
blocked when ``git worktree list`` is mocked/unavailable.
"""
root = os.path.realpath(canonical_repo_root)
try:
res = subprocess.run(
["git", "-C", root, "worktree", "list", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
except Exception:
return None
if res.returncode != 0:
return None
inventory: list[str] = []
for line in (res.stdout or "").splitlines():
if line.startswith("worktree "):
raw = line[len("worktree ") :].strip()
if raw:
inventory.append(os.path.realpath(raw))
if not inventory:
return None
return os.path.realpath(path) in inventory
def assess_bound_worktree_existence(
*,
configured_path: str,
binding_source: str,
canonical_repo_root: str | None = None,
role_kind: str = "author",
profile_name: str | None = None,
) -> dict:
"""Fail closed when a configured role-bound worktree path is missing (#618)."""
raw = (configured_path or "").strip()
if not raw:
return {
"proven": True,
"block": False,
"bound_worktree_missing": False,
"path_exists": None,
"in_git_worktree_list": None,
"inspected_git_root": None,
"reasons": [],
"configured_path": None,
"binding_source": binding_source,
"role_kind": role_kind,
"profile_name": profile_name,
"blocker_kind": None,
"operator_recovery": None,
}
try:
real = _abs_real(raw)
except OSError:
real = os.path.abspath(raw)
path_exists = os.path.isdir(real)
in_list: bool | None = None
inspected_git_root: str | None = None
root = (canonical_repo_root or "").strip()
if root:
in_list = path_in_git_worktree_list(real, root) if path_exists else False
if path_exists:
try:
res = subprocess.run(
["git", "-C", real, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
inspected_git_root = (res.stdout or "").strip() or None
except Exception:
inspected_git_root = None
if path_exists:
return {
"proven": True,
"block": False,
"bound_worktree_missing": False,
"path_exists": True,
"in_git_worktree_list": in_list,
"inspected_git_root": inspected_git_root,
"reasons": [],
"configured_path": real,
"binding_source": binding_source,
"role_kind": role_kind,
"profile_name": profile_name,
"blocker_kind": None,
"operator_recovery": None,
}
reasons = [
BOUND_WORKTREE_MISSING_MESSAGE,
(
f"role/profile '{profile_name or role_kind}' binding via {binding_source} "
f"points to '{real}' which does not exist on disk"
),
f"path_exists=false; in_git_worktree_list={in_list}; inspected_git_root=null",
]
return {
"proven": False,
"block": True,
"bound_worktree_missing": True,
"path_exists": False,
"in_git_worktree_list": False if in_list is not None else False,
"inspected_git_root": None,
"reasons": reasons,
"configured_path": real,
"binding_source": binding_source,
"role_kind": role_kind,
"profile_name": profile_name,
"blocker_kind": BOUND_WORKTREE_MISSING,
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
}
def format_bound_worktree_missing_error(assessment: dict) -> str:
"""Canonical operator-facing message for a missing author worktree binding."""
reasons = list(assessment.get("reasons") or [BOUND_WORKTREE_MISSING_MESSAGE])
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
profile = assessment.get("profile_name") or assessment.get("role_kind") or "author"
source = (
assessment.get("binding_source")
or assessment.get("workspace_binding_source")
or "unknown binding"
)
path = (
assessment.get("configured_path")
or assessment.get("workspace_path")
or "(unknown)"
)
return (
f"Author worktree binding unhealthy (#618): {'; '.join(reasons)}. "
f"role/profile: {profile}; binding_source: {source}; configured_path: {path}. "
f"Operator recovery: {recovery}"
)
def assess_lock_worktree_ownership(
*,
workspace_path: str,
session_lock_worktree: str | None,
) -> dict:
"""When a live lock records a worktree, mutation workspace must match it."""
locked = (session_lock_worktree or "").strip()
if not locked:
return {
"proven": True,
"block": False,
"reasons": [],
"workspace_path": os.path.realpath(workspace_path) if workspace_path else None,
"lock_worktree_path": None,
}
workspace = os.path.realpath(workspace_path)
locked_real = os.path.realpath(locked)
if workspace != locked_real:
return {
"proven": False,
"block": True,
"reasons": [
f"active author issue lock worktree '{locked_real}' does not match "
f"mutation workspace '{workspace}' (lock ownership, fail closed)"
],
"workspace_path": workspace,
"lock_worktree_path": locked_real,
}
return {
"proven": True,
"block": False,
"reasons": [],
"workspace_path": workspace,
"lock_worktree_path": locked_real,
}
def resolve_durable_author_worktree(
*,
worktree_path: str | None = None,
worktree: str | None = None,
process_project_root: str,
active_worktree_env: str | None = None,
author_worktree_env: str | None = None,
session_lock_worktree: str | None = None,
canonical_repo_root: str | None = None,
profile_name: str | None = None,
validate: bool = True,
) -> dict:
"""Resolve author mutation workspace without silent control-checkout fallback (#618).
Candidate priority:
1. explicit ``worktree_path`` argument
2. ``worktree`` argument
3. ``GITEA_ACTIVE_WORKTREE``
4. ``GITEA_AUTHOR_WORKTREE``
5. active author issue lock ``worktree_path``
6. process project root **only** when it is already under ``branches/``
Configured bindings that point at a missing path fail closed immediately
(no demotion to the control checkout). Validation (when *validate*) covers
existence, traversal/symlink safety, repository identity, branches/
containment, and lock ownership.
"""
process_root = os.path.realpath(process_project_root)
canonical = os.path.realpath(canonical_repo_root or process_root)
reasons: list[str] = []
role = "author"
candidates: list[tuple[str | None, str, bool]] = [
(worktree_path, "worktree_path argument", False),
(worktree, "worktree argument", False),
(active_worktree_env, f"{ACTIVE_WORKTREE_ENV} environment variable", True),
(author_worktree_env, f"{AUTHOR_WORKTREE_ENV} environment variable", True),
(session_lock_worktree, "active author issue lock worktree", False),
]
selected_path: str | None = None
selected_source: str | None = None
existence: dict | None = None
for candidate, source, _configured in candidates:
text = (candidate or "").strip()
if not text:
continue
try:
real = _abs_real(text)
except OSError:
real = os.path.abspath(text)
existence = assess_bound_worktree_existence(
configured_path=real,
binding_source=source,
canonical_repo_root=canonical,
role_kind=role,
profile_name=profile_name,
)
if existence["block"]:
# Missing configured binding: fail closed, never fall back (#618).
return {
"proven": False,
"block": True,
"workspace_path": real,
"workspace_binding_source": source,
"process_project_root": process_root,
"canonical_repo_root": canonical,
"bound_worktree_missing": True,
"path_exists": False,
"in_git_worktree_list": existence.get("in_git_worktree_list"),
"inspected_git_root": None,
"reasons": list(existence.get("reasons") or []),
"blocker_kind": BOUND_WORKTREE_MISSING,
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
"silent_control_fallback": False,
}
selected_path = real
selected_source = source
break
if selected_path is None:
# No explicit/env/lock binding. Allow process root only when it is a
# branches/ worktree (MCP launched from the task worktree). Never
# silently bind the stable control checkout.
if is_path_under_branches(process_root, canonical):
selected_path = process_root
selected_source = "MCP process root under branches/ (session-owned)"
else:
return {
"proven": False,
"block": True,
"workspace_path": process_root,
"workspace_binding_source": "no author worktree binding",
"process_project_root": process_root,
"canonical_repo_root": canonical,
"bound_worktree_missing": False,
"path_exists": os.path.isdir(process_root),
"in_git_worktree_list": None,
"inspected_git_root": None,
"reasons": [
"author mutation blocked: workspace is the stable control checkout; "
"author mutation requires an explicit validated worktree_path "
"or a worktree derived from the active author issue lock; "
"silent fallback to the control checkout/master is forbidden (#618)"
],
"blocker_kind": "author_worktree_unbound_control_checkout",
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
"silent_control_fallback": False,
}
workspace = selected_path
source = selected_source or "unknown"
path_exists = os.path.isdir(workspace)
inspected_git_root: str | None = None
in_list: bool | None = None
if not validate:
return {
"proven": True,
"block": False,
"workspace_path": workspace,
"workspace_binding_source": source,
"process_project_root": process_root,
"canonical_repo_root": canonical,
"bound_worktree_missing": False,
"path_exists": path_exists,
"in_git_worktree_list": None,
"inspected_git_root": None,
"reasons": [],
"blocker_kind": None,
"operator_recovery": None,
"silent_control_fallback": False,
}
# Traversal / symlink safety
safety = assess_path_traversal_safety(
path=workspace, canonical_repo_root=canonical
)
if safety["block"]:
reasons.extend(safety["reasons"])
else:
workspace = safety["workspace_path"] or workspace
# Existence + git inventory
if not path_exists:
reasons.append(BOUND_WORKTREE_MISSING_MESSAGE)
reasons.append(f"resolved worktree '{workspace}' does not exist")
else:
in_list = path_in_git_worktree_list(workspace, canonical)
try:
res = subprocess.run(
["git", "-C", workspace, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
inspected_git_root = (res.stdout or "").strip() or None
except Exception:
inspected_git_root = None
if in_list is False:
# Only hard-fail when inventory was obtained and the path is absent.
reasons.append(
f"worktree '{workspace}' is not listed in git worktree list for "
f"'{canonical}' (fail closed)"
)
# Repository identity
if path_exists:
membership = assess_workspace_repo_membership(
workspace_path=workspace,
canonical_repo_root=canonical,
)
if membership["block"]:
reasons.extend(membership["reasons"])
# branches/ containment
branches = assess_author_mutation_worktree(
workspace_path=workspace,
project_root=canonical,
)
if branches["block"]:
reasons.extend(branches["reasons"])
# Lock ownership (when a lock worktree is recorded)
lock_own = assess_lock_worktree_ownership(
workspace_path=workspace,
session_lock_worktree=session_lock_worktree,
)
if lock_own["block"]:
reasons.extend(lock_own["reasons"])
# Forbid resolved control checkout even if somehow selected
if workspace == canonical or workspace == process_root:
if not is_path_under_branches(workspace, canonical):
if not any("control checkout" in r for r in reasons):
reasons.append(
"author mutation blocked: resolved workspace is the stable "
"control checkout; silent fallback forbidden (#618)"
)
block = bool(reasons)
bound_missing = any("does not exist" in r or BOUND_WORKTREE_MISSING_MESSAGE in r for r in reasons)
return {
"proven": not block,
"block": block,
"workspace_path": workspace,
"workspace_binding_source": source,
"process_project_root": process_root,
"canonical_repo_root": canonical,
"bound_worktree_missing": bound_missing,
"path_exists": path_exists,
"in_git_worktree_list": in_list,
"inspected_git_root": inspected_git_root,
"reasons": reasons,
"blocker_kind": BOUND_WORKTREE_MISSING if bound_missing else (
"author_worktree_validation_failed" if block else None
),
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT if block else None,
"silent_control_fallback": False,
}
def format_durable_author_worktree_error(assessment: dict) -> str:
"""Format fail-closed error for durable author worktree resolution."""
if assessment.get("bound_worktree_missing") or assessment.get("blocker_kind") == BOUND_WORKTREE_MISSING:
return format_bound_worktree_missing_error(assessment)
workspace = assessment.get("workspace_path") or "(unknown)"
source = assessment.get("workspace_binding_source") or "unknown"
reasons = "; ".join(
assessment.get("reasons") or ["author worktree resolution failed"]
)
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
return (
f"Durable author worktree resolution blocked (#618): {reasons}. "
f"workspace: {workspace}; binding_source: {source}. "
f"Operator recovery: {recovery}"
)
+438 -14
View File
@@ -29,7 +29,9 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Iterator, Sequence
SCHEMA_VERSION = 3
import dependency_graph
SCHEMA_VERSION = 4
# Assignable work kinds only — raw monitoring incidents are never work items.
WORK_KINDS = frozenset({"issue", "pr"})
@@ -147,7 +149,41 @@ CREATE TABLE IF NOT EXISTS incident_links (
UNIQUE (provider, provider_base_url, provider_org, provider_project, provider_issue_id)
);
-- Durable dependency graph (#784, umbrella #628 scope item 6). Dependencies
-- were previously re-parsed per allocation run and discarded; each row here is
-- one relationship with its conditions, current state, and evidence. Creating
-- the table is itself the v3→v4 migration: additive, idempotent, and it never
-- touches the pre-existing tables.
CREATE TABLE IF NOT EXISTS dependency_edges (
edge_id TEXT PRIMARY KEY,
remote TEXT NOT NULL,
org TEXT NOT NULL,
repo TEXT NOT NULL,
source_kind TEXT NOT NULL CHECK (source_kind IN ('issue', 'pr')),
source_number INTEGER NOT NULL,
target_kind TEXT NOT NULL CHECK (target_kind IN ('issue', 'pr')),
target_number INTEGER NOT NULL,
edge_type TEXT NOT NULL,
blocking_condition TEXT NOT NULL DEFAULT '',
completion_condition TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL,
evidence TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_observed_at TEXT NOT NULL,
UNIQUE (
remote, org, repo, source_kind, source_number,
target_kind, target_number, edge_type
)
);
CREATE INDEX IF NOT EXISTS idx_leases_work_status ON leases(work_item_id, status);
-- Reverse lookup ("what waits on this target") is the query automatic
-- resumption needs, so it gets its own index alongside the forward one.
CREATE INDEX IF NOT EXISTS idx_dependency_edges_source
ON dependency_edges(remote, org, repo, source_kind, source_number);
CREATE INDEX IF NOT EXISTS idx_dependency_edges_target
ON dependency_edges(remote, org, repo, target_kind, target_number);
CREATE INDEX IF NOT EXISTS idx_assignments_session ON assignments(session_id, status);
CREATE INDEX IF NOT EXISTS idx_incident_gitea ON incident_links(gitea_org, gitea_repo, gitea_issue_number);
"""
@@ -302,6 +338,7 @@ class ControlPlaneDB:
conn.executescript(_SCHEMA_SQL)
self._migrate_incident_links_null_scope(conn)
self._migrate_lease_lifecycle_columns(conn)
self._migrate_session_ownership_columns(conn)
conn.execute(
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION)),
@@ -492,32 +529,62 @@ class ControlPlaneDB:
namespace: str | None = None,
pid: int | None = None,
status: str = "active",
controller_instance_id: str | None = None,
) -> dict[str, Any]:
"""Register/refresh a session row.
*controller_instance_id* (#765) is the stable identity of the
controller that owns this session. Session ids are regenerated per
invocation, so they cannot express "my own in-progress task"; the
controller instance can. It is never overwritten with ``None``, so a
heartbeat from a caller that does not supply one cannot erase
ownership.
"""
now = _ts()
instance = (controller_instance_id or "").strip() or None
with self._tx() as conn:
existing = conn.execute(
"SELECT session_id FROM sessions WHERE session_id = ?",
(session_id,),
).fetchone()
if existing:
conn.execute(
"""
UPDATE sessions
SET role = ?, profile = ?, namespace = ?, pid = ?,
last_heartbeat_at = ?, status = ?
WHERE session_id = ?
""",
(role, profile, namespace, pid, now, status, session_id),
)
if instance is None:
conn.execute(
"""
UPDATE sessions
SET role = ?, profile = ?, namespace = ?, pid = ?,
last_heartbeat_at = ?, status = ?
WHERE session_id = ?
""",
(role, profile, namespace, pid, now, status, session_id),
)
else:
conn.execute(
"""
UPDATE sessions
SET role = ?, profile = ?, namespace = ?, pid = ?,
last_heartbeat_at = ?, status = ?,
controller_instance_id = ?
WHERE session_id = ?
""",
(
role, profile, namespace, pid, now, status,
instance, session_id,
),
)
else:
conn.execute(
"""
INSERT INTO sessions(
session_id, role, profile, namespace, pid,
started_at, last_heartbeat_at, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
started_at, last_heartbeat_at, status,
controller_instance_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(session_id, role, profile, namespace, pid, now, now, status),
(
session_id, role, profile, namespace, pid, now, now,
status, instance,
),
)
row = conn.execute(
"SELECT * FROM sessions WHERE session_id = ?",
@@ -1215,6 +1282,73 @@ class ControlPlaneDB:
if name not in cols:
conn.execute(f"ALTER TABLE leases ADD COLUMN {name} {decl}")
_SESSION_OWNERSHIP_COLUMNS: tuple[tuple[str, str], ...] = (
("controller_instance_id", "TEXT"),
)
def _migrate_session_ownership_columns(self, conn: sqlite3.Connection) -> None:
"""Add the stable controller identity to sessions (#765).
Pre-existing rows migrate with ``NULL``. A NULL instance is treated as
*unknown ownership* by the allocator and is never silently adopted.
"""
cols = {
row[1]
for row in conn.execute("PRAGMA table_info(sessions)").fetchall()
}
if not cols:
return
for name, decl in self._SESSION_OWNERSHIP_COLUMNS:
if name not in cols:
conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {decl}")
def list_active_claims(
self,
*,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
role: str | None = None,
limit: int = 500,
) -> dict[tuple[str, int], dict[str, Any]]:
"""Map ``(work_kind, work_number)`` to its live claim (#765).
Only ``active`` leases count as claims; released/expired rows never
withhold work. Callers compare the returned ``controller_instance_id``
against their own to decide own-task vs foreign-task.
"""
claims: dict[tuple[str, int], dict[str, Any]] = {}
for row in self.list_leases(
remote=remote,
org=org,
repo=repo,
role=role,
statuses=("active",),
limit=limit,
):
kind = str(row.get("work_kind") or "").strip().lower()
number = row.get("work_number")
if not kind or number is None:
continue
key = (kind, int(number))
claim = {
"lease_id": row.get("lease_id"),
"session_id": row.get("session_id"),
"controller_instance_id": row.get("session_controller_instance_id"),
"role": row.get("role"),
"profile": row.get("session_profile"),
"expires_at": row.get("expires_at"),
"work_kind": kind,
"work_number": int(number),
}
# Keep the longest-lived claim when duplicates exist.
previous = claims.get(key)
if previous is None or str(claim["expires_at"] or "") > str(
previous["expires_at"] or ""
):
claims[key] = claim
return claims
def _lease_columns(self, conn: sqlite3.Connection) -> set[str]:
return {
row[1]
@@ -1256,7 +1390,8 @@ class ControlPlaneDB:
w.number AS work_number, w.state AS work_state,
w.current_head_sha AS work_head_sha,
s.pid AS session_pid, s.profile AS session_profile,
s.status AS session_status
s.status AS session_status,
s.controller_instance_id AS session_controller_instance_id
FROM leases l
JOIN work_items w ON w.work_item_id = l.work_item_id
LEFT JOIN sessions s ON s.session_id = l.session_id
@@ -1749,3 +1884,292 @@ class ControlPlaneDB:
f"transferred lease ownership from {owner} to {adopter_session_id}"
],
}
# --- Dependency graph (#784, umbrella #628 scope item 6) ----------------
@staticmethod
def _dependency_edge_row(row: sqlite3.Row | None) -> dict[str, Any] | None:
"""Return a stored edge as a plain dict with evidence decoded."""
if row is None:
return None
edge = dict(row)
raw = edge.get("evidence")
try:
edge["evidence"] = json.loads(raw) if raw else {}
except (TypeError, ValueError):
# A row written by an older/foreign writer must not break reads.
edge["evidence"] = {"unparsed": str(raw)}
return edge
def upsert_dependency_edge(
self,
*,
remote: str,
org: str,
repo: str,
source_kind: str,
source_number: int,
target_kind: str,
target_number: int,
edge_type: str,
state: str,
blocking_condition: str | None = None,
completion_condition: str | None = None,
evidence: Any = None,
) -> dict[str, Any]:
"""Insert or refresh one dependency edge, keyed by its relationship.
Uniqueness is (scope, source, target, edge_type), so re-observing the
same relationship updates one row instead of appending history — the
edge is current state, and transitions are recorded as ``events``.
Edge type, state, and both endpoint kinds are validated fail-closed;
an unknown value writes nothing. Evidence is sanitized before storage.
"""
edge_type_norm = dependency_graph.normalize_edge_type(edge_type)
state_norm = dependency_graph.normalize_edge_state(state)
source_kind_norm = dependency_graph.normalize_work_kind(source_kind)
target_kind_norm = dependency_graph.normalize_work_kind(target_kind)
source_no = int(source_number)
target_no = int(target_number)
if blocking_condition is None or completion_condition is None:
defaults = dependency_graph.default_conditions(edge_type_norm)
blocking_condition = (
defaults[0] if blocking_condition is None else blocking_condition
)
completion_condition = (
defaults[1] if completion_condition is None else completion_condition
)
evidence_json = json.dumps(
dependency_graph.sanitize_evidence(evidence if evidence is not None else {})
)
now_s = _ts()
with self._tx() as conn:
existing = conn.execute(
"""
SELECT * FROM dependency_edges
WHERE remote = ? AND org = ? AND repo = ?
AND source_kind = ? AND source_number = ?
AND target_kind = ? AND target_number = ? AND edge_type = ?
""",
(
remote,
org,
repo,
source_kind_norm,
source_no,
target_kind_norm,
target_no,
edge_type_norm,
),
).fetchone()
if existing is None:
edge_id = uuid.uuid4().hex
conn.execute(
"""
INSERT INTO dependency_edges(
edge_id, remote, org, repo,
source_kind, source_number, target_kind, target_number,
edge_type, blocking_condition, completion_condition,
state, evidence, created_at, updated_at, last_observed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
edge_id,
remote,
org,
repo,
source_kind_norm,
source_no,
target_kind_norm,
target_no,
edge_type_norm,
blocking_condition,
completion_condition,
state_norm,
evidence_json,
now_s,
now_s,
now_s,
),
)
else:
edge_id = str(existing["edge_id"])
conn.execute(
"""
UPDATE dependency_edges
SET blocking_condition = ?, completion_condition = ?,
state = ?, evidence = ?, updated_at = ?,
last_observed_at = ?
WHERE edge_id = ?
""",
(
blocking_condition,
completion_condition,
state_norm,
evidence_json,
now_s,
now_s,
edge_id,
),
)
prior_state = str(existing["state"])
if prior_state != state_norm:
self._record_edge_transition_conn(
conn,
edge_id=edge_id,
prior_state=prior_state,
new_state=state_norm,
detail="observed during upsert",
now_s=now_s,
)
row = conn.execute(
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
).fetchone()
return self._dependency_edge_row(row) or {}
@staticmethod
def _record_edge_transition_conn(
conn: sqlite3.Connection,
*,
edge_id: str,
prior_state: str,
new_state: str,
detail: str,
now_s: str,
) -> None:
"""Append a state transition to the shared ``events`` audit table.
``work_item_id`` stays NULL: an edge endpoint is a Gitea issue/PR that
may never have been assigned, so it has no work_items row to reference.
"""
message = (
f"dependency edge {edge_id} state {prior_state} -> {new_state}"
f" ({detail})"
)
conn.execute(
"""
INSERT INTO events(work_item_id, event_type, message, created_at)
VALUES (NULL, 'dependency_edge_state_change', ?, ?)
""",
(message, now_s),
)
def list_dependency_edges(
self,
*,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
source_kind: str | None = None,
source_number: int | None = None,
target_kind: str | None = None,
target_number: int | None = None,
edge_type: str | None = None,
state: str | None = None,
limit: int = 500,
) -> list[dict[str, Any]]:
"""Return stored edges, filtered.
Filtering by *target* answers "what is waiting on this work unit",
which is the query automatic resumption needs and which body-text
parsing could never serve.
"""
clauses: list[str] = []
params: list[Any] = []
if remote:
clauses.append("remote = ?")
params.append(remote)
if org:
clauses.append("org = ?")
params.append(org)
if repo:
clauses.append("repo = ?")
params.append(repo)
if source_kind:
clauses.append("source_kind = ?")
params.append(dependency_graph.normalize_work_kind(source_kind))
if source_number is not None:
clauses.append("source_number = ?")
params.append(int(source_number))
if target_kind:
clauses.append("target_kind = ?")
params.append(dependency_graph.normalize_work_kind(target_kind))
if target_number is not None:
clauses.append("target_number = ?")
params.append(int(target_number))
if edge_type:
clauses.append("edge_type = ?")
params.append(dependency_graph.normalize_edge_type(edge_type))
if state:
clauses.append("state = ?")
params.append(dependency_graph.normalize_edge_state(state))
sql = "SELECT * FROM dependency_edges"
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY source_number ASC, target_number ASC, edge_type ASC LIMIT ?"
params.append(int(limit))
with self._tx(immediate=False) as conn:
rows = conn.execute(sql, params).fetchall()
return [edge for edge in (self._dependency_edge_row(r) for r in rows) if edge]
def record_dependency_edge_observation(
self,
edge_id: str,
*,
state: str,
evidence: Any = None,
detail: str = "observation recorded",
) -> dict[str, Any]:
"""Update an existing edge's state and evidence, auditing the change.
A transition writes an ``events`` row carrying both the prior and the
new state, so a later blocked/resume decision can be reconstructed from
durable state rather than from a recomputed reason string.
"""
state_norm = dependency_graph.normalize_edge_state(state)
now_s = _ts()
with self._tx() as conn:
existing = conn.execute(
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
).fetchone()
if existing is None:
raise ControlPlaneError(
f"dependency edge '{edge_id}' does not exist (fail closed)"
)
prior_state = str(existing["state"])
if evidence is None:
evidence_json = str(existing["evidence"] or "{}")
else:
evidence_json = json.dumps(
dependency_graph.sanitize_evidence(evidence)
)
conn.execute(
"""
UPDATE dependency_edges
SET state = ?, evidence = ?, updated_at = ?, last_observed_at = ?
WHERE edge_id = ?
""",
(state_norm, evidence_json, now_s, now_s, edge_id),
)
if prior_state != state_norm:
self._record_edge_transition_conn(
conn,
edge_id=edge_id,
prior_state=prior_state,
new_state=state_norm,
detail=detail,
now_s=now_s,
)
row = conn.execute(
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
).fetchone()
edge = self._dependency_edge_row(row) or {}
edge["prior_state"] = prior_state
edge["state_changed"] = prior_state != state_norm
return edge
+359
View File
@@ -0,0 +1,359 @@
"""Sanctioned pre-issue bootstrap for ``create_issue`` (#749).
``gitea_create_issue`` is a pure remote mutation: it creates a tracking issue
and writes nothing to the local working tree. The issue-first gate forbids
creating ``branches/issue-<N>-*`` before the issue number exists, while the
#274 branches-only guard previously demanded that worktree first — a deadlock.
This module defines a **narrow, phase-scoped** exemption:
* Only tasks in :data:`CREATE_ISSUE_TASKS` may use it.
* Only the **canonical control checkout** may be used (never an arbitrary
directory, unrelated worktree, or foreign clone).
* The control checkout must be clean, on an accepted base branch, and
base-equivalent to live master when a remote tip is known.
* Every post-creation author mutation keeps the ordinary ``branches/`` rule.
The exemption cannot widen: unknown tasks, dirty roots, drifted HEADs, non-base
branches, and non-control workspaces fall through to the existing fail-closed
guards.
"""
from __future__ import annotations
import os
from typing import Any
from author_mutation_worktree import BASE_BRANCHES, is_path_under_branches
from reviewer_worktree import parse_dirty_tracked_files
CREATE_ISSUE_TASKS = frozenset({"create_issue", "gitea_create_issue"})
# Satisfiable before an issue number exists — never names issue-<N>.
EXACT_NEXT_ACTION_BOOTSTRAP = (
"Restore the canonical control checkout to a clean accepted base branch "
"(master/main/dev) that matches live master, with no tracked local edits "
"and no detached HEAD. Re-resolve the exact create_issue task, then re-run "
"gitea_create_issue from that clean control checkout. Do not create "
"branches/issue-<N>-* worktrees, dummy directories, or borrow unrelated "
"worktrees before the issue exists."
)
EXACT_NEXT_ACTION_POST_CREATE = (
"After the issue exists: create a registered worktree under "
"branches/issue-<N>-* from clean master, claim/lock the issue, set "
"GITEA_AUTHOR_WORKTREE / worktree_path to that path, then continue author "
"mutations from the issue-backed worktree only."
)
def is_create_issue_task(task: str | None) -> bool:
"""True when *task* is the create_issue mutation (or tool alias)."""
return (task or "").strip() in CREATE_ISSUE_TASKS
def normalize_sha(value: str | None) -> str | None:
"""Normalize a Git object id for comparison, or ``None`` when unknown.
Whitespace and case are the only permitted variation between two spellings
of the same commit; anything else is a different commit. Empty and
whitespace-only values normalize to ``None`` so an unknown tip can never
compare equal to another unknown tip.
"""
normalized = (value or "").strip().lower()
return normalized or None
def assess_create_issue_bootstrap(
*,
workspace_path: str,
canonical_repo_root: str,
current_branch: str | None = None,
head_sha: str | None = None,
porcelain_status: str = "",
remote_master_sha: str | None = None,
remote_master_sha_error: str | None = None,
task: str | None = None,
) -> dict[str, Any]:
"""Assess whether create_issue may proceed from the control checkout.
Returns a structured assessment:
* ``not_applicable`` — not a create_issue task, or workspace is already a
``branches/`` worktree (use ordinary guards).
* ``allowed`` — create_issue bootstrap may proceed from this control root.
* ``block`` — create_issue was attempted from control checkout but gates
failed (dirty, wrong branch, base race, etc.).
"""
reasons: list[str] = []
root = os.path.realpath(canonical_repo_root or "")
workspace = os.path.realpath(workspace_path or root or ".")
branch = (current_branch or "").strip()
dirty = parse_dirty_tracked_files(porcelain_status or "")
under_branches = is_path_under_branches(workspace, root) if root else False
if not is_create_issue_task(task):
return _result(
not_applicable=True,
allowed=False,
block=False,
reasons=["task is not create_issue"],
workspace=workspace,
root=root,
branch=branch,
dirty=dirty,
under_branches=under_branches,
)
# Registered branches/ worktrees keep the normal path (no bootstrap).
if under_branches:
return _result(
not_applicable=True,
allowed=False,
block=False,
reasons=["workspace is under branches/; ordinary #274 path applies"],
workspace=workspace,
root=root,
branch=branch,
dirty=dirty,
under_branches=True,
)
# Only the exact canonical control checkout is eligible.
if not root or workspace != root:
reasons.append(
"create_issue bootstrap requires the canonical control checkout; "
f"workspace '{workspace}' is not the repository root '{root or '(unknown)'}'"
)
return _result(
not_applicable=False,
allowed=False,
block=True,
reasons=reasons,
workspace=workspace,
root=root,
branch=branch,
dirty=dirty,
under_branches=False,
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
)
if dirty:
reasons.append(
"create_issue bootstrap blocked: control checkout has tracked local "
f"edits (dirty files: {', '.join(dirty)})"
)
if not branch:
reasons.append(
"create_issue bootstrap blocked: control checkout is detached HEAD; "
"expected an accepted base branch (master/main/dev)"
)
elif branch not in BASE_BRANCHES:
reasons.append(
f"create_issue bootstrap blocked: control checkout branch '{branch}' "
f"is not an accepted base branch ({'/'.join(sorted(BASE_BRANCHES))})"
)
# #757 AC3/AC4: base-equivalence must be *proven*, never assumed. An
# unknown tip on either side is not evidence of agreement, so a missing
# local HEAD, an unresolvable live master, or a resolver failure all block.
remote_tip = normalize_sha(remote_master_sha)
local_tip = normalize_sha(head_sha)
resolver_error = (remote_master_sha_error or "").strip() or None
if not local_tip:
reasons.append(
"create_issue bootstrap blocked: control checkout HEAD SHA is "
"unknown; base equivalence to live master cannot be proven "
"(fail closed)"
)
if resolver_error:
reasons.append(
"create_issue bootstrap blocked: live master tip could not be "
f"resolved ({resolver_error}); base equivalence cannot be proven "
"(fail closed)"
)
elif not remote_tip:
reasons.append(
"create_issue bootstrap blocked: live master tip is unknown; base "
"equivalence to live master cannot be proven (fail closed)"
)
if remote_tip and local_tip and remote_tip != local_tip:
reasons.append(
"create_issue bootstrap blocked: control checkout HEAD does not match "
f"live master (HEAD {local_tip[:12]}, live master {remote_tip[:12]})"
)
if reasons:
return _result(
not_applicable=False,
allowed=False,
block=True,
reasons=reasons,
workspace=workspace,
root=root,
branch=branch or None,
dirty=dirty,
under_branches=False,
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
local_head_sha=local_tip,
remote_master_sha=remote_tip,
)
return _result(
not_applicable=False,
allowed=True,
block=False,
reasons=[],
workspace=workspace,
root=root,
branch=branch or None,
dirty=dirty,
under_branches=False,
exact_next_action=EXACT_NEXT_ACTION_POST_CREATE,
bootstrap_path="clean_canonical_control_checkout",
local_head_sha=local_tip,
remote_master_sha=remote_tip,
)
def bootstrap_permits_control_checkout(
assessment: Any,
*,
task: str | None,
workspace_path: str | None,
canonical_repo_root: str | None,
) -> bool:
"""Single interpretation of a bootstrap assessment (#757).
Both author-mutation guards — the #274 branches-only enforcer and the #604
anti-stomp preflight — route their "may this workspace mutate" decision
through this predicate, so the two can never reach opposite conclusions
about identical evidence.
Fail-closed by construction. Every proof obligation must be present and
affirmative in *assessment*, and the assessment must describe the very
workspace and canonical root being guarded. A missing, malformed, refused,
incomplete, or contradictory assessment returns ``False``, which leaves the
caller's ordinary block in force.
``assessment`` is server-derived only: it is produced by
:func:`assess_create_issue_bootstrap` from inspected repository state. It is
never accepted from an MCP tool argument, so no caller can assert
eligibility it has not proven.
"""
if not isinstance(assessment, dict):
return False
if not is_create_issue_task(task):
return False
# Positive proof: the assessment must affirmatively allow, with no
# competing refusal or not-applicable disposition recorded alongside it.
if assessment.get("allowed") is not True:
return False
if assessment.get("proven") is not True:
return False
if assessment.get("block") is not False:
return False
if assessment.get("not_applicable") is not False:
return False
if assessment.get("reasons"):
return False
# Scope proof: only the create_issue bootstrap, only via the clean
# canonical control checkout path.
if assessment.get("task_scope") != "create_issue_only":
return False
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
return False
# State proof: clean, and not a branches/ worktree (those keep #274).
if assessment.get("dirty_files"):
return False
if assessment.get("under_branches") is not False:
return False
# Base-equivalence proof (#757 AC3/AC4): both tips must be recorded,
# nonempty, and equal. Re-derived here rather than trusted from the
# assessment's own flag, so a hand-built or truncated assessment cannot
# assert agreement it never proved.
if assessment.get("base_tips_verified") is not True:
return False
local_tip = normalize_sha(assessment.get("local_head_sha"))
remote_tip = normalize_sha(assessment.get("remote_master_sha"))
if not local_tip or not remote_tip or local_tip != remote_tip:
return False
# Binding proof: the assessment must describe *this* workspace and root,
# and that workspace must be exactly the canonical control checkout.
root = os.path.realpath(canonical_repo_root or "")
workspace = os.path.realpath(workspace_path or root or ".")
if not root or workspace != root:
return False
if assessment.get("canonical_repo_root") != root:
return False
if assessment.get("workspace_path") != workspace:
return False
return True
def format_create_issue_bootstrap_error(assessment: dict[str, Any]) -> str:
"""RuntimeError / typed-block message for a failed bootstrap assessment."""
reasons = "; ".join(
assessment.get("reasons") or ["create_issue bootstrap failed"]
)
next_action = (
assessment.get("exact_next_action") or EXACT_NEXT_ACTION_BOOTSTRAP
)
root = assessment.get("canonical_repo_root") or "(unknown)"
workspace = assessment.get("workspace_path") or "(unknown)"
return (
f"Create-issue bootstrap guard (#749): {reasons}. "
f"canonical repository root: {root}; workspace: {workspace}. "
f"exact_next_action: {next_action}"
)
def _result(
*,
not_applicable: bool,
allowed: bool,
block: bool,
reasons: list[str],
workspace: str,
root: str,
branch: str | None,
dirty: list[str],
under_branches: bool,
exact_next_action: str | None = None,
bootstrap_path: str | None = None,
local_head_sha: str | None = None,
remote_master_sha: str | None = None,
) -> dict[str, Any]:
# #757 AC3/AC4: equality is recorded only when BOTH tips are known, so a
# consumer can never read agreement out of two missing values.
base_tips_verified = bool(
local_head_sha and remote_master_sha and local_head_sha == remote_master_sha
)
return {
"not_applicable": not_applicable,
"allowed": allowed,
"block": block,
"proven": allowed and not block,
"reasons": list(reasons),
"workspace_path": workspace,
"canonical_repo_root": root,
"current_branch": branch,
"dirty_files": list(dirty),
"under_branches": under_branches,
"exact_next_action": exact_next_action,
"bootstrap_path": bootstrap_path,
"task_scope": "create_issue_only",
"local_head_sha": local_head_sha,
"remote_master_sha": remote_master_sha,
"base_tips_verified": base_tips_verified,
}
+307
View File
@@ -0,0 +1,307 @@
"""Durable dependency-edge vocabulary for the control plane (#784, umbrella #628).
Umbrella #628 scope item 6 requires dependencies to be durable structured state
carrying source, target, type, blocking condition, completion condition, current
state, and evidence. Before this module the only dependency knowledge in the
system was the per-run parse performed by :mod:`allocator_dependencies`, which
collapsed into two in-memory ``WorkCandidate`` fields and was then discarded.
This module owns the vocabulary half of that store:
* the seven relationship types #628 enumerates;
* the three observation states, matching the outcome of
:func:`allocator_dependencies.resolve_dependency_state`;
* fail-closed normalization for both, plus for work kinds;
* the default blocking/completion condition text for each type;
* evidence sanitization, so no credential or endpoint ever reaches the store.
Persistence lives in :mod:`control_plane_db`; ingestion from a live allocation
run is :func:`record_issue_dependency_edges`. Nothing here changes allocator
selection — this slice records the graph, it does not act on it.
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Mapping
# --- Work kinds -------------------------------------------------------------
# Mirrors control_plane_db.WORK_KINDS. Declared locally so this module stays
# import-light and usable from the DB layer without a circular import.
WORK_KIND_ISSUE = "issue"
WORK_KIND_PR = "pr"
WORK_KINDS = frozenset({WORK_KIND_ISSUE, WORK_KIND_PR})
# --- Edge types (#628 scope item 6) -----------------------------------------
EDGE_ISSUE_BLOCKED_BY_ISSUE = "issue_blocked_by_issue"
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES = "pr_waiting_for_requested_changes"
EDGE_MERGE_WAITING_FOR_APPROVAL = "merge_waiting_for_approval"
EDGE_RECONCILIATION_WAITING_FOR_MERGE = "reconciliation_waiting_for_merge"
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE = "deployment_waiting_for_infrastructure"
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION = "acceptance_waiting_for_validation"
EDGE_TASK_WAITING_FOR_DEFECT_FIX = "task_waiting_for_defect_fix"
EDGE_TYPES: frozenset[str] = frozenset(
{
EDGE_ISSUE_BLOCKED_BY_ISSUE,
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
EDGE_MERGE_WAITING_FOR_APPROVAL,
EDGE_RECONCILIATION_WAITING_FOR_MERGE,
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
EDGE_TASK_WAITING_FOR_DEFECT_FIX,
}
)
# --- Edge states ------------------------------------------------------------
# Deliberately three-valued: unavailable evidence is never recorded as met,
# matching resolve_dependency_state's fail-closed contract (#758 AC6/AC7).
STATE_UNMET = "unmet"
STATE_MET = "met"
STATE_UNAVAILABLE = "unavailable"
EDGE_STATES: frozenset[str] = frozenset({STATE_UNMET, STATE_MET, STATE_UNAVAILABLE})
# Default condition text per edge type: (blocking_condition, completion_condition).
DEFAULT_CONDITIONS: dict[str, tuple[str, str]] = {
EDGE_ISSUE_BLOCKED_BY_ISSUE: (
"target issue is not closed",
"target issue is closed",
),
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES: (
"requested changes are outstanding at the current head",
"requested changes are addressed at the current head",
),
EDGE_MERGE_WAITING_FOR_APPROVAL: (
"no approval exists at the current head",
"an approval exists at the current head",
),
EDGE_RECONCILIATION_WAITING_FOR_MERGE: (
"target pull request is not merged",
"target pull request is merged",
),
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE: (
"required infrastructure is unavailable",
"required infrastructure is available",
),
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION: (
"required validation evidence is missing",
"required validation evidence is recorded",
),
EDGE_TASK_WAITING_FOR_DEFECT_FIX: (
"blocking defect is unresolved or undeployed",
"blocking defect is fixed and the runtime carries the fix",
),
}
class DependencyGraphError(ValueError):
"""Base error for dependency-edge vocabulary violations."""
class InvalidEdgeTypeError(DependencyGraphError):
"""Raised when an edge type outside :data:`EDGE_TYPES` is supplied."""
class InvalidEdgeStateError(DependencyGraphError):
"""Raised when a state outside :data:`EDGE_STATES` is supplied."""
class InvalidEdgeEndpointError(DependencyGraphError):
"""Raised when an edge endpoint is not an assignable work unit."""
def normalize_edge_type(value: Any) -> str:
"""Return the canonical edge type, or raise fail-closed.
Unknown values are never coerced to a default: an unrecognized relationship
would be stored as an unqueryable free-text row and would silently break
reverse lookup for whichever consumer expected the real type.
"""
text = str(value or "").strip().lower()
if text not in EDGE_TYPES:
raise InvalidEdgeTypeError(
f"unknown dependency edge_type '{value}'; expected one of "
f"{sorted(EDGE_TYPES)} (fail closed)"
)
return text
def normalize_edge_state(value: Any) -> str:
"""Return the canonical edge state, or raise fail-closed."""
text = str(value or "").strip().lower()
if text not in EDGE_STATES:
raise InvalidEdgeStateError(
f"unknown dependency edge state '{value}'; expected one of "
f"{sorted(EDGE_STATES)} (fail closed)"
)
return text
def normalize_work_kind(value: Any) -> str:
"""Return the canonical work kind for an edge endpoint, or raise."""
text = str(value or "").strip().lower()
if text not in WORK_KINDS:
raise InvalidEdgeEndpointError(
f"dependency edge endpoint kind '{value}' is not assignable work; "
f"expected one of {sorted(WORK_KINDS)} (never raw incidents)"
)
return text
def default_conditions(edge_type: str) -> tuple[str, str]:
"""Return ``(blocking_condition, completion_condition)`` for *edge_type*."""
return DEFAULT_CONDITIONS[normalize_edge_type(edge_type)]
# --- Evidence sanitization --------------------------------------------------
_SECRET_KEY_PATTERN = re.compile(
r"token|secret|password|passwd|authorization|auth_header|credential|api_key"
r"|apikey|private_key|cookie|session_token",
re.IGNORECASE,
)
_URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
REDACTED = "[redacted]"
# Evidence is a small observation record; a deep or huge payload is a sign the
# caller is dumping API responses into the store.
_MAX_EVIDENCE_DEPTH = 6
_MAX_EVIDENCE_STRING = 2000
def sanitize_evidence(payload: Any, *, _depth: int = 0) -> Any:
"""Return *payload* with credentials and endpoint URLs removed.
Applies to every stored evidence record. Keys naming a secret are replaced
wholesale; any value containing a URL has the URL replaced, so an endpoint
can never be persisted or handed back through a read tool.
"""
if _depth > _MAX_EVIDENCE_DEPTH:
return REDACTED
if isinstance(payload, Mapping):
clean: dict[str, Any] = {}
for key, value in payload.items():
name = str(key)
if _SECRET_KEY_PATTERN.search(name):
clean[name] = REDACTED
else:
clean[name] = sanitize_evidence(value, _depth=_depth + 1)
return clean
if isinstance(payload, (list, tuple)):
return [sanitize_evidence(item, _depth=_depth + 1) for item in payload]
if isinstance(payload, str):
text = _URL_PATTERN.sub(REDACTED, payload)
if len(text) > _MAX_EVIDENCE_STRING:
text = text[:_MAX_EVIDENCE_STRING] + ""
return text
if isinstance(payload, (int, float, bool)) or payload is None:
return payload
return sanitize_evidence(str(payload), _depth=_depth + 1)
# --- Ingestion from a live allocation run -----------------------------------
# Observed live state as recorded in evidence. The exact Gitea state string is
# not stored for the unmet case: resolve_dependency_state has already reduced
# "any live value other than closed" to unmet, and re-deriving it here would
# invent evidence the resolver never produced.
OBSERVED_CLOSED = "closed"
OBSERVED_NOT_CLOSED = "not_closed"
OBSERVED_UNAVAILABLE = "unavailable"
OBSERVATION_SOURCE_ALLOCATOR = "allocator_live_issue_lookup"
_OBSERVED_STATE_BY_EDGE_STATE = {
STATE_MET: OBSERVED_CLOSED,
STATE_UNMET: OBSERVED_NOT_CLOSED,
STATE_UNAVAILABLE: OBSERVED_UNAVAILABLE,
}
def _observation(state: str, *, observed_by: str | None, subject: str) -> dict[str, Any]:
return {
"observed_state": _OBSERVED_STATE_BY_EDGE_STATE[state],
"observation_source": OBSERVATION_SOURCE_ALLOCATOR,
"observed_by_session": observed_by,
"declaration": "Depends declaration in issue body",
"subject": subject,
}
def edges_from_dependency_resolution(
resolution: Mapping[str, Any],
*,
source_number: int,
observed_by: str | None = None,
) -> list[dict[str, Any]]:
"""Convert one resolver result into edge records ready for persistence.
*resolution* is the dict returned by
:func:`allocator_dependencies.resolve_dependency_state`. Its ``met`` /
``unmet`` / ``unavailable`` partitions map one-to-one onto the stored
states, so no dependency is re-classified here.
"""
subject = f"issue#{int(source_number)}"
blocking, completion = default_conditions(EDGE_ISSUE_BLOCKED_BY_ISSUE)
records: list[dict[str, Any]] = []
partitions: tuple[tuple[str, Iterable[Any]], ...] = (
(STATE_MET, resolution.get("met") or ()),
(STATE_UNMET, resolution.get("unmet") or ()),
(STATE_UNAVAILABLE, resolution.get("unavailable") or ()),
)
for state, refs in partitions:
for ref in refs:
records.append(
{
"source_kind": WORK_KIND_ISSUE,
"source_number": int(source_number),
"target_kind": WORK_KIND_ISSUE,
"target_number": int(ref),
"edge_type": EDGE_ISSUE_BLOCKED_BY_ISSUE,
"state": state,
"blocking_condition": blocking,
"completion_condition": completion,
"evidence": _observation(
state, observed_by=observed_by, subject=subject
),
}
)
return records
def record_issue_dependency_edges(
db: Any,
*,
remote: str,
org: str,
repo: str,
source_number: int,
resolution: Mapping[str, Any],
observed_by: str | None = None,
) -> list[str]:
"""Persist the edges implied by one candidate's dependency resolution.
Best-effort by contract: allocation correctness must not depend on this
store existing or being writable, so every failure is returned as a reason
string and never raised. The caller keeps using the in-memory resolution it
already holds.
"""
try:
records = edges_from_dependency_resolution(
resolution, source_number=source_number, observed_by=observed_by
)
except Exception as exc: # noqa: BLE001 — ingestion never breaks allocation
return [f"dependency edge ingestion skipped for issue#{source_number}: {exc}"]
reasons: list[str] = []
for record in records:
try:
db.upsert_dependency_edge(remote=remote, org=org, repo=repo, **record)
except Exception as exc: # noqa: BLE001 — see docstring
reasons.append(
f"dependency edge not persisted for issue#{source_number}"
f"issue#{record['target_number']}: {exc}"
)
return reasons
@@ -171,13 +171,18 @@ then:
- Does not replace CI or code review for MCP changes
- Does not authorize editing stable checkout “because tests need a quick fix”
## 5. Implementation follow-ups (optional tooling)
## 5. Implementation follow-ups
These may land in later issues; the **policy binds sessions now**:
The **policy binds sessions now**. The enforcement layer landed with issue #615
acceptance criteria 611 in `stable_control_runtime.py`:
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
3. Promotion checklist script that emits the durable promotion marker fields.
1. ~~Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”~~ **Landed.** `_runtime_mode_block()` refuses every mutating operation from a `dev-test`, dev-worktree-launched, dirty-stable, misaligned, or `unknown` runtime; `gitea.read` is never blocked, so an operator can still diagnose a sick runtime.
2. ~~Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.~~ **Landed** as `runtime_mode` (`stable-control` | `dev-test` | `unknown`), reported by `gitea_get_runtime_context` under `stable_control_runtime` together with the runtime git SHA, branch, checkout path, process root, active workspace, alignment, dirty files, and `real_mutations_allowed`. Operators running a packaged layout with no git checkout declare the mode explicitly with `GITEA_MCP_RUNTIME_MODE`.
3. ~~Promotion checklist script that emits the durable promotion marker fields.~~ **Landed** as `scripts/promote-stable-runtime` (read-only; emits and validates the record) plus [`../stable-runtime-promotion-runbook.md`](../stable-runtime-promotion-runbook.md).
Post-transport-flap proof is enforced per namespace: a flap invalidates every
`gitea-*` namespace at once, and author proof never transfers to reviewer,
merger, or reconciler (`namespace_not_reproven_after_flap`).
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
+38
View File
@@ -317,6 +317,44 @@ Least-privilege constraints:
canonical names such as `gitea.pr.close` (never bare `pr.close` /
`issue.close`, which the production normalizer rejects or drops).
### Post-merge moot-lease cleanup ownership (`gitea.pr.comment`)
Neutralising a reviewer lease left behind on an already-merged/closed PR is
reconciliation work too. `task_capability_map` maps
`cleanup_post_merge_moot_lease` — and its tool-name alias
`gitea_cleanup_post_merge_moot_lease` — to role `reconciler` with permission
`gitea.pr.comment` (#745). Both names carry the **same** contract.
The permission alone is deliberately not sufficient: author, reviewer and
merger profiles all hold `gitea.pr.comment` for ordinary PR discussion, so the
role gate — not the permission gate — is what keeps the terminal lease marker
reconciler-owned.
`gitea_cleanup_post_merge_moot_lease` splits its two modes on purpose:
- **`apply=false` (assessment) requires only `gitea.read`, with no role gate.**
This matches `gitea_cleanup_stale_review_decision_lock` and
`gitea_cleanup_obsolete_reviewer_comment_lease`, whose assessment paths are
likewise read-gated, so an operator can diagnose a stuck lease from whichever
namespace happens to be attached without switching roles. The dry run
performs no mutation and records append-only evidence in-session.
- **`apply=true` (mutation) requires all of the following**, in order: the
session must have resolved exactly `cleanup_post_merge_moot_lease` (resolving
any other task — including a sibling reconciler task — does not authorize
it); the active role must be `reconciler`; the profile must hold
`gitea.pr.comment`; the explicit `org`/`repo` must agree with the canonical
repository identity, which is derived from the session binding and can never
be overridden by request parameters; and matching dry-run evidence must show
`lease_moot`, `cleanup_allowed`, and the same PR, lease session, candidate
head and lease marker id that are live at apply time.
Everything else fails closed: a live lease on an open PR, an already-terminal
(idempotent) lease, a lease superseded between the dry run and the apply, a
malformed lease missing session/head/marker, and any foreign-repository target.
The cleanup only ever appends a terminal `phase: released` marker
(`blocker: post-merge-moot`) — it never edits or deletes another session's
comment, and it never merges or adopts a lease.
Launch a static `gitea-reconciler` MCP namespace with
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
`reconciler_profile.assess_reconciler_profile` (#304). Use the
+42
View File
@@ -131,6 +131,44 @@ Suggested lifecycle:
The helper module `issue_workflow_labels.py` is the source of truth for the
canonical label specs and status transition replacement behavior.
## Terminal PR transitions retire `status:pr-open` (#780)
`status:pr-open` states that a linked PR is *currently open*. The moment that
stops being true the label must go, whatever ended the PR:
| Terminal reason | Raised by |
|---|---|
| `merged` | `gitea_merge_pr` |
| `closed_without_merge` | `gitea_edit_pr` closing the PR |
| `superseded` | `gitea_reconcile_superseded_by_merged_pr` |
| `already_landed` | `gitea_reconcile_already_landed_pr` |
| `controller_closure` | `gitea_close_issue` |
| `abandoned` | abandonment handling |
| `retry_recovery` | `gitea_cleanup_terminal_pr_labels` after a partial failure |
All of these route through one rule in `terminal_pr_label_cleanup.py`, so the
paths cannot drift apart. The rule guarantees:
- only `status:pr-open` is removed — every other label is preserved verbatim;
- an empty resulting label set is valid (it was the issue's only label);
- an issue that no longer carries the label is a no-op, so retries are safe;
- the result is confirmed by a read-after-write re-read, not assumed.
Controller closure runs the cleanup **before** changing issue state and fails
closed if it cannot be completed and verified — closing first would bake in the
stale label with no later step to catch it. Post-merge cleanup never blocks the
merge: the transition already happened, so failures are reported with a
`safe_next_action` instead.
Use `gitea_assess_terminal_label_hygiene` as terminal validation before
declaring a transition or cleanup batch complete. It enumerates issues plus the
live open PRs and reports any issue still carrying `status:pr-open` without an
open PR to justify it. Issues with a genuinely open PR are exempt, not
residual.
Recovery from a partial failure is `gitea_cleanup_terminal_pr_labels` with
`terminal_reason='retry_recovery'`.
## Discussion Issues
Discussion issues must be labeled `type:discussion`.
@@ -157,6 +195,10 @@ If a discussion produces implementation work, either:
be applied to the locked issue, then applies it after the PR is created.
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
sessions can satisfy the branches-only mutation guard while changing labels.
- `gitea_cleanup_terminal_pr_labels` retires `status:pr-open` after a terminal
PR transition; it is idempotent, so it is also the retry/recovery path.
- `gitea_assess_terminal_label_hygiene` is the read-only terminal validation
for residual `status:pr-open`.
## Existing Non-Workflow Labels
+14 -1
View File
@@ -48,6 +48,16 @@ It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
be adapted to other repositories.
### Sanctioned first mutation: `create_issue` from clean control (#749)
Creating a tracking issue has no issue number yet, so no `branches/issue-<N>-*`
worktree can exist. The sanctioned path is: clean canonical control checkout
(accepted base branch, base-equivalent to live master, no tracked dirt) →
resolve exact `create_issue``gitea_create_issue`. After the issue exists,
all further author mutations require a registered issue-backed worktree and
lock. Do not improvise with dummy directories, borrowed worktrees, or pre-issue
worktrees. See `skills/llm-project-workflow/workflows/create-issue.md` §18a.
## Principle: the profile is the role, not the LLM
```text
@@ -696,7 +706,9 @@ do **not** improvise shell wrappers or fall back to direct API / temp scripts.
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
update tests if behavior changes; run the full suite; commit with an
issue-linked message; open a PR to `master`; move the issue to
`status:pr-open`. **Do not** review or merge your own PR. Include an
`status:pr-open` (every terminal transition later retires that label
automatically — see [`label-taxonomy.md`](label-taxonomy.md)). **Do not**
review or merge your own PR. Include an
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
[`llm-agent-sha.md`](llm-agent-sha.md).
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
@@ -1231,6 +1243,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
## Related documents
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
- [`stable-runtime-promotion-runbook.md`](stable-runtime-promotion-runbook.md) — operator promotion procedure, required promotion-record fields, per-namespace post-flap re-proving, and rollback for the stable control runtime (#615).
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
+17
View File
@@ -40,6 +40,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
| Option | Description |
|--------|-------------|
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
| Workflow dashboard (queue, leases, next safe action) | Documents the read-only `gitea_workflow_dashboard` MCP tool (#605): live PR/issue queues, leases by role, terminal review lock, blocked items, and exact next-safe prompts. **Does not assign work** — assignment still uses `gitea_allocate_next_work`. Never presents blocked/terminal-locked items as safe. The shell entry is documentation only (no Gitea mutation). |
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
@@ -50,6 +51,22 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
| Exit | Quit the menu. |
### Workflow dashboard MCP tool (#605)
From any healthy Gitea MCP namespace with `gitea.read`:
```text
gitea_workflow_dashboard(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
```
Response includes `human_summary` plus structured queues, `active_leases_by_role`,
`terminal_review_lock`, `blocked_items`, `next_safe_by_role`, and
`primary_next_safe_action`. Incomplete inventory fails closed.
## Placeholder-only entries
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
+41
View File
@@ -110,8 +110,49 @@ healthy. See `docs/mcp-namespace-health.md`.
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
reconnect.
## Sanctioned recovery vs forbidden process manipulation (#630)
Both restore a working namespace. Only one leaves the session trustworthy.
**Sanctioned — the runtime is repaired by whoever owns it:**
- IDE/host auto-reconnect, or an explicit client reconnect (`/mcp reconnect`).
- Relaunching the IDE/client so it respawns the daemons it started.
- An operator-owned restart performed outside the workflow session.
**Forbidden — the session manipulates the processes its own proof depends on:**
- `pkill -f mcp_server.py`, `pkill -f gitea_mcp_server`, broad `pkill -f mcp`.
- `killall` of a daemon, or `kill <pid>` of an MCP daemon pid.
- Any pattern broad enough to take unrelated namespaces with it
(`pkill -f python`), even when it never names MCP.
Read-only inspection (`ps aux | grep mcp_server`) is neither: it proves nothing
and breaks nothing. A `kill` of some unrelated pid is reported as *ambiguous*
rather than contaminating, so ordinary subprocess work is never false-blocked.
**What happens on a detected attempt.** `gitea_record_daemon_process_kill_attempt`
classifies a proposed command and, when it is a manual daemon kill, writes a
durable contamination marker for the active profile identity. While that marker
is live every review / merge / close / completion mutation fails closed;
`comment_issue` and `lock_issue` stay allowed so the contaminated worker can
still post its audit comment and hand off. The final report must surface the
contaminated recovery and must not claim a clean session.
Contamination is **not self-clearable**. Only
`gitea_audit_runtime_recovery_contamination` with `action=clear`, run under a
reconciler profile, removes it. The marker is recovery-critical, so it does not
expire into cleanliness when the session-state TTL lapses.
**Operator-authorized host maintenance stays permitted.** Authorization is read
from the `GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION` environment variable
and from nowhere else — set outside the session by the operator who owns the
host, and recorded as an audit reference on the assessment. It is deliberately
not a tool argument: a session must never be able to authorize itself.
## Related
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
- `docs/mcp-client-registration.md` — per-server registration contract.
+172
View File
@@ -0,0 +1,172 @@
# Registered MCP tool inventory
This is the canonical list of tools the Gitea-Tools MCP server registers. It
exists because documentation and the registered inventory drifted: the workflow
documented a `gitea_edit_issue` tool that no namespace had ever registered, so a
mutation could be planned against a tool that did not exist and only fail at
execution time (#781).
## The rule
**Documentation must never name a tool an actor cannot reach.**
Two guards enforce it, both in `tests/test_issue_781_edit_issue_tool.py`:
1. The list below must equal the registered tool set exactly — sorted, no
duplicates, nothing missing in either direction. Adding a tool without
documenting it fails, and documenting a tool without registering it fails.
2. Every backticked `gitea_*` / `mcp_*` identifier in `skills/**/*.md` must be a
registered tool. Module and script names that share the prefix are listed
explicitly in `mcp_tool_inventory.NON_TOOL_IDENTIFIERS` rather than being
waved through by a looser pattern.
## Updating this file
When you add or remove an `@mcp.tool()`, regenerate the block below:
```bash
PYTEST_CURRENT_TEST=1 venv/bin/python -c "
import mcp_server, mcp_tool_inventory
print(mcp_tool_inventory.render_inventory_block(
mcp_server.mcp._tool_manager._tools))
"
```
Replace everything between the markers with that output. Do not hand-edit
individual entries — the generator and the guard share one ordering rule.
## Registered tools
Namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`)
register the same tool set; what differs per namespace is the execution profile
that gates each call, not which tools exist.
<!-- BEGIN REGISTERED TOOL INVENTORY -->
- `gitea_abandon_workflow_lease`
- `gitea_acquire_conflict_fix_lease`
- `gitea_acquire_merger_pr_lease`
- `gitea_acquire_reviewer_pr_lease`
- `gitea_activate_profile`
- `gitea_adopt_merger_pr_lease`
- `gitea_adopt_workflow_lease`
- `gitea_allocate_next_work`
- `gitea_assess_already_landed_reconciliation`
- `gitea_assess_conflict_fix_classification`
- `gitea_assess_conflict_fix_push`
- `gitea_assess_gitea_operation_path`
- `gitea_assess_master_parity`
- `gitea_assess_mcp_namespace_health`
- `gitea_assess_pr_sync_status`
- `gitea_assess_review_merge_state_machine`
- `gitea_assess_reviewer_pr_lease`
- `gitea_assess_terminal_label_hygiene`
- `gitea_assess_work_issue_duplicate`
- `gitea_assess_worktree_cleanup_integrity`
- `gitea_audit_config`
- `gitea_audit_runtime_recovery_contamination`
- `gitea_audit_stable_branch_contamination`
- `gitea_audit_worktree_cleanup`
- `gitea_authorize_reconciliation_cleanup_phase`
- `gitea_authorize_review_correction`
- `gitea_capability_stop_terminal_report`
- `gitea_capture_branches_worktree_snapshot`
- `gitea_check_pr_eligibility`
- `gitea_cleanup_merged_pr_branch`
- `gitea_cleanup_obsolete_reviewer_comment_lease`
- `gitea_cleanup_post_merge_moot_lease`
- `gitea_cleanup_stale_claims`
- `gitea_cleanup_stale_review_decision_lock`
- `gitea_cleanup_terminal_pr_labels`
- `gitea_close_issue`
- `gitea_commit_files`
- `gitea_consume_irrecoverable_decision_lock_provenance`
- `gitea_create_issue`
- `gitea_create_issue_comment`
- `gitea_create_label`
- `gitea_create_pr`
- `gitea_delete_branch`
- `gitea_diagnose_review_decision_lock`
- `gitea_diagnose_reviewer_pr_lease_handoff`
- `gitea_diagnose_terminal`
- `gitea_dry_run_pr_review`
- `gitea_edit_issue`
- `gitea_edit_pr`
- `gitea_expire_workflow_leases`
- `gitea_get_authenticated_user`
- `gitea_get_current_user`
- `gitea_get_file`
- `gitea_get_pr_review_feedback`
- `gitea_get_profile`
- `gitea_get_runtime_context`
- `gitea_get_shell_health`
- `gitea_heartbeat_reviewer_pr_lease`
- `gitea_inspect_workflow_lease`
- `gitea_issue_irrecoverable_provenance_authorization`
- `gitea_list_dependency_edges`
- `gitea_list_issue_comments`
- `gitea_list_issues`
- `gitea_list_labels`
- `gitea_list_profiles`
- `gitea_list_prs`
- `gitea_list_workflow_leases`
- `gitea_load_review_workflow`
- `gitea_lock_issue`
- `gitea_mark_final_review_decision`
- `gitea_mark_issue`
- `gitea_merge_pr`
- `gitea_mirror_refs`
- `gitea_observability_link_issue`
- `gitea_observability_list_projects`
- `gitea_observability_reconcile_incident`
- `gitea_post_heartbeat`
- `gitea_quarantine_contaminated_review`
- `gitea_reclaim_expired_workflow_lease`
- `gitea_reconcile_already_landed_pr`
- `gitea_reconcile_issue_claims`
- `gitea_reconcile_merged_cleanups`
- `gitea_reconcile_superseded_by_merged_pr`
- `gitea_record_daemon_process_kill_attempt`
- `gitea_record_irrecoverable_decision_lock_provenance`
- `gitea_record_pre_review_command`
- `gitea_record_shell_spawn_outcome`
- `gitea_record_stable_branch_push_attempt`
- `gitea_release_merger_pr_lease`
- `gitea_release_reviewer_pr_lease`
- `gitea_release_workflow_lease`
- `gitea_resolve_task_capability`
- `gitea_resume_review_draft`
- `gitea_review_pr`
- `gitea_route_task_session`
- `gitea_save_review_draft`
- `gitea_scan_already_landed_open_prs`
- `gitea_sentry_get_issue_events`
- `gitea_sentry_link_gitea_issue`
- `gitea_sentry_list_issues`
- `gitea_sentry_reconcile_issue`
- `gitea_sentry_watchdog`
- `gitea_set_issue_labels`
- `gitea_submit_pr_review`
- `gitea_update_pr_branch_by_merge`
- `gitea_validate_review_final_report`
- `gitea_view_issue`
- `gitea_view_pr`
- `gitea_whoami`
- `gitea_workflow_dashboard`
- `mcp_check_workflow_skill_preflight`
- `mcp_get_control_plane_guide`
- `mcp_get_skill_guide`
- `mcp_list_project_skills`
<!-- END REGISTERED TOOL INVENTORY -->
## Issue-content editing
`gitea_edit_issue` is the only path that changes an issue's title or body. It
PATCHes the issue endpoint, refuses a pull-request number, sends only the fields
the caller named, and proves the result by read-after-write — including that
state, labels, assignees, and milestone did not move.
`gitea_edit_pr` remains pull-request-only. The two paths never merge: a single
tool that accepted either kind would make the narrower capability reachable
through the wider one.
+53 -1
View File
@@ -131,7 +131,59 @@ and `incident_links` rows.
- The bridge remains the **only** sanctioned route from an alert back into
Gitea workflow state.
## 7. Non-goals
## 7. Reading Sentry back into Gitea (#607)
[`sentry_incident_bridge.py`](../../sentry_incident_bridge.py) supplies the
**read** half of the inbound path: it pulls unresolved issues/events from the
self-hosted Sentry API, normalizes them into #612 observations, and hands them
to `incident_bridge.reconcile_incident`. It never adds a second linking store —
`incident_links` on the #613 control-plane DB stays canonical, which is what
makes the mapping survive restarts.
### Configuration
| Variable | Purpose | Default |
| --- | --- | --- |
| `SENTRY_BASE_URL` | Self-hosted Sentry root | `https://sentry.prgs.cc` |
| `SENTRY_AUTH_TOKEN` | API token — **env only**, never logged or returned | _(unset)_ |
| `SENTRY_ORG` | Sentry organization slug | _(unset)_ |
| `SENTRY_PROJECT` | Sentry project slug | _(unset)_ |
| `MCP_SENTRY_ISSUE_BRIDGE_ENABLED` | Required for `apply=true` | `false` |
| `MCP_SENTRY_MIN_EVENTS_FOR_ISSUE` | Recurrence threshold before an issue is worth filing | `2` |
| `MCP_SENTRY_LOOKBACK` | Scan window (`statsPeriod`, e.g. `24h`) | `24h` |
Missing org/project fails closed as `not_configured`; a missing token fails
closed as `missing_token` **before** any HTTP call is made.
### Tools
| Tool | Mode | Purpose |
| --- | --- | --- |
| `gitea_sentry_list_issues` | read-only | Unresolved issues, `Link`-header pagination |
| `gitea_sentry_get_issue_events` | read-only | Sanitized recent + latest event for one issue |
| `gitea_sentry_reconcile_issue` | dry-run default | One Sentry issue → durable Gitea issue |
| `gitea_sentry_link_gitea_issue` | dry-run default | Link a Sentry issue to an existing Gitea issue |
| `gitea_sentry_watchdog` | dry-run default | Scan + create/update issues for active incidents |
### Policy
- **Dedupe:** one Sentry issue maps to exactly one Gitea issue, keyed by
provider + base URL + org + project + issue id. Recurrence updates the link
(and its `event_count`) instead of filing a duplicate.
- **No reopen:** a Sentry issue that is no longer `unresolved` is skipped; the
bridge never reopens or re-files a closed Gitea issue.
- **Threshold:** issues below `MCP_SENTRY_MIN_EVENTS_FOR_ISSUE` are skipped, so
one-off noise does not become durable work.
- **Apply is explicit:** `apply=true` requires both
`MCP_SENTRY_ISSUE_BRIDGE_ENABLED` and issue-create permission on the profile.
- **Outages fail closed:** an unreachable Sentry returns `sentry_unavailable`
and creates nothing.
- **Redaction:** secrets are scrubbed and absolute local paths are reduced to a
category token (`[path:author]`, `[path:root]`, …) before any value reaches a
Gitea issue body. Sensitive tag keys (`authorization`, `cookie`, …) are
dropped, and permalinks carrying embedded credentials are discarded entirely.
## 8. Non-goals
- Sentry must **not** become the workflow source of truth.
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
+121
View File
@@ -0,0 +1,121 @@
# Stable control runtime — promotion runbook (#615)
Operator / release-manager procedure for promoting a revision into the **stable
control runtime**: the Gitea MCP server that performs real issue/PR mutations.
Policy source: [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md).
Enforcement: `stable_control_runtime.py` (runtime mode classification, mutation
gates, per-namespace post-flap re-proving, promotion-record validation).
**Promotion is operator-owned.** Normal author / reviewer / merger / reconciler
sessions must never kill, restart, or relaunch the MCP server, and must never
edit the stable runtime checkout. A session that needs newer server code stops
with `BLOCKED + DIAGNOSE` and hands off to the operator.
---
## 1. When a promotion is required
- A merged PR changes MCP server code the control plane must now enforce.
- `gitea_assess_master_parity` reports `stale: true` / `restart_required: true`.
- `gitea_get_runtime_context` reports a `runtime_mode` other than
`stable-control`, or `real_mutations_allowed: false`.
## 2. Pre-promotion checks
Run these **before** advancing the stable checkout:
1. The target revision is on remote `master` and was merged through
`gitea_merge_pr` (never a direct stable-branch push — see #671).
2. The stable control checkout is clean (`git status --porcelain` empty) and on
`master`. A dirty stable runtime is itself a mutation blocker.
3. The advance is strictly fast-forwardable: local `master` is an ancestor of
`prgs/master`.
4. No active workflow lease is mid-mutation (`gitea_list_workflow_leases`).
## 3. Promotion steps
1. Record the **previous** runtime SHA (`gitea_assess_master_parity`
`startup_head`).
2. `git fetch --prune prgs` in the stable control checkout.
3. `git merge --ff-only prgs/master` — never rebase, reset, or force.
4. Record the **promoted** runtime SHA (`git rev-parse HEAD`).
5. Reload the runtime using the sanctioned client path (IDE/client reconnect or
the operator's supervised service reload). Never `pkill` the daemon from a
workflow session.
6. Re-prove **each** namespace independently (see §5).
7. Record the promotion (see §4) and post it as a durable comment on the
tracking issue.
## 4. Promotion record (required fields)
Every promotion must record all of the following. `assess_promotion_record()`
validates them and fails closed on any missing field, or when
`previous_runtime_sha` equals `promoted_runtime_sha` (nothing was promoted).
| Field | Meaning |
|-------|---------|
| `previous_runtime_sha` | SHA the stable runtime was serving before promotion |
| `promoted_runtime_sha` | SHA the stable runtime serves after promotion |
| `source_branch` | Branch the promoted revision came from |
| `source_pr` | PR number that merged it |
| `restart_method` | Exact reload/restart mechanism the operator used |
| `health_check_proof` | `gitea_assess_mcp_namespace_health` result per namespace |
| `identity_proof` | `gitea_whoami` username + profile per namespace |
| `profile_proof` | `gitea_get_runtime_context` active profile per namespace |
| `workspace_proof` | Process root, canonical root, alignment, clean state |
| `mutation_capability_proof` | `gitea_resolve_task_capability` for the intended task |
| `rollback_instructions` | Exact steps to return to `previous_runtime_sha` |
Helper: `scripts/promote-stable-runtime` emits and validates the record. It
never restarts anything — it reads state and prints the record for the operator
to act on and archive.
## 5. Post-promotion namespace re-proving
A restart or transport flap drops every `gitea-*` namespace together. Author
proof is **not** global proof. For each of `author`, `reviewer`, `merger`,
`reconciler`, in that namespace:
1. `gitea_whoami`
2. `gitea_get_runtime_context`
3. `gitea_resolve_task_capability` immediately before the intended mutation
4. Mutate only when no reconnect / restart / stale-runtime gate is reported
Until a namespace passes all four, its mutations stay blocked with
`namespace_not_reproven_after_flap`.
## 6. Rollback
If the promoted runtime is unhealthy — namespace EOF that does not recover,
identity or profile mismatch, capability resolution failure, or an unexpected
`runtime_mode`:
1. **Stop all PR/review/merge work.** An unhealthy stable runtime fails closed;
do not route around it.
2. Fast-forward or check out `previous_runtime_sha` in the stable checkout.
3. Reload the runtime by the same sanctioned method.
4. Re-prove every namespace (§5).
5. Record the rollback as a promotion record whose `promoted_runtime_sha` is the
restored SHA, with the failure evidence in `health_check_proof`.
## 7. Runtime modes seen in reports
| Mode | Meaning | Real mutations |
|------|---------|----------------|
| `stable-control` | Promoted revision, stable branch, clean checkout | Allowed |
| `dev-test` | Launched from a `branches/` worktree or a feature branch | Blocked against production |
| `unknown` | Root unresolvable, not a git checkout, or detached HEAD with no declaration | Blocked |
A packaged deployment with no git checkout must declare itself explicitly with
`GITEA_MCP_RUNTIME_MODE=stable-control`; an unset or misspelled value falls back
to inference and, failing that, to `unknown`.
## 8. Related
- `architecture/mcp-stable-control-runtime-policy-adr.md` — the policy (#615)
- `mcp-namespace-health.md` — client-namespace health (#543)
- `mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery
- `mcp-daemon-import-guard.md` — sanctioned daemon only (#558)
- `bootstrap-review-path.md` — controller bootstrap when the live runtime cannot
review its own fix (#557)
+271
View File
@@ -0,0 +1,271 @@
"""Authoritative rule for editing an issue's title and body (#781).
The workflow documented a ``gitea_edit_issue`` tool that was never registered,
so an authorized body correction on an issue had no sanctioned path at all: the
only edit tool, ``gitea_edit_pr``, PATCHes the pull-request endpoint and cannot
target an issue. This module is the rule that path is built on, kept separate
from the pull-request edit path by construction.
- :func:`validate_edit_request` rejects structurally invalid requests before any
credential, network, or profile work happens. A request that names no field,
or names one with the wrong type, is a pure input error.
- :func:`assess_issue_target` refuses a pull request. Gitea serves pull requests
from the same ``/issues/{n}`` collection, so without this check the issue edit
path would quietly become a second, ungated PR edit path.
- :func:`plan_issue_edit` decides the exact PATCH payload from the pre-image. It
only ever sends fields the caller named, and it reports a request that would
change nothing as an explicit no-op rather than a silent success.
- :func:`verify_issue_edit` is the read-after-write check. It proves the applied
title/body match what was requested *and* that every field the caller did not
name — state, labels, assignees, milestone — is unchanged.
This module performs no I/O — callers own the Gitea API calls.
"""
from __future__ import annotations
from typing import Any, Mapping
import issue_workflow_labels
#: Fields this tool is allowed to change. Anything else must be untouched.
EDITABLE_FIELDS: tuple[str, ...] = ("title", "body")
#: Fields the caller never names and which must survive an edit verbatim.
PRESERVED_FIELDS: tuple[str, ...] = ("state", "labels", "assignees", "milestone")
def validate_edit_request(
title: str | None = None,
body: str | None = None,
) -> dict[str, str]:
"""Return the requested field map, failing closed on an invalid request.
Raises ``ValueError`` when no field is named, when a named field is not a
string, or when a title is blank. An empty *body* is legitimate — clearing
an issue description is a real edit — but an empty title is not, because
Gitea has no issue without one.
"""
requested: dict[str, str] = {}
if title is not None:
if not isinstance(title, str):
raise ValueError(
f"Invalid title type {type(title).__name__}: title must be a "
"string (fail closed)."
)
if not title.strip():
raise ValueError(
"Invalid title: an issue title cannot be blank. Pass the exact "
"replacement title, or omit title= to leave it unchanged "
"(fail closed)."
)
requested["title"] = title
if body is not None:
if not isinstance(body, str):
raise ValueError(
f"Invalid body type {type(body).__name__}: body must be a "
"string (fail closed)."
)
requested["body"] = body
if not requested:
raise ValueError(
"At least one field to edit (title, body) must be provided. "
"gitea_edit_issue never edits state, labels, assignees, or "
"milestone (fail closed)."
)
return requested
def assess_issue_target(
issue: Mapping[str, Any],
*,
issue_number: int,
) -> dict[str, Any]:
"""Confirm the fetched object is an issue and not a pull request.
Gitea serves pull requests from ``/issues/{n}`` as well, so a PR number
reaches this path unchallenged. Issue and pull-request edits stay separate
capabilities, so a PR target is refused here rather than silently PATCHed.
"""
is_pull_request = bool(issue.get("pull_request"))
return {
"is_issue": not is_pull_request,
"is_pull_request": is_pull_request,
"reasons": (
[
f"#{issue_number} is a pull request, not an issue; "
"gitea_edit_issue never edits pull requests"
]
if is_pull_request
else []
),
"safe_next_action": (
f"Use gitea_edit_pr for pull request #{issue_number}."
if is_pull_request
else ""
),
}
def preserved_snapshot(issue: Mapping[str, Any]) -> dict[str, Any]:
"""Capture the fields an edit must leave alone, in a comparable shape."""
return {
"state": issue.get("state"),
"labels": issue_workflow_labels.label_names(issue),
"assignees": _assignee_names(issue),
"milestone": _milestone_key(issue),
}
def _assignee_names(issue: Mapping[str, Any]) -> list[str]:
names: list[str] = []
for entry in issue.get("assignees") or []:
if isinstance(entry, Mapping):
login = entry.get("login") or entry.get("username")
else:
login = entry
if login:
names.append(str(login))
return names
def _milestone_key(issue: Mapping[str, Any]) -> str | None:
milestone = issue.get("milestone")
if not milestone:
return None
if isinstance(milestone, Mapping):
key = milestone.get("title") or milestone.get("id")
return None if key is None else str(key)
return str(milestone)
def plan_issue_edit(
current: Mapping[str, Any],
*,
title: str | None = None,
body: str | None = None,
issue_number: int | None = None,
) -> dict[str, Any]:
"""Plan the PATCH payload for an issue edit against its pre-image.
Only fields the caller named are ever put in the payload, so unspecified
fields cannot be overwritten with a stale read. A request whose named fields
already hold the requested values is reported as a no-op with an actionable
reason instead of being sent and reported as a success.
"""
requested = validate_edit_request(title=title, body=body)
number = issue_number if issue_number is not None else current.get("number")
changes: dict[str, dict[str, Any]] = {}
unchanged: list[str] = []
for field, value in requested.items():
before = current.get(field)
if field == "body":
before = before or ""
if before == value:
unchanged.append(field)
else:
changes[field] = {"before": before, "after": value}
no_op = not changes
payload = {field: requested[field] for field in changes}
return {
"issue_number": number,
"requested_fields": sorted(requested),
"requested": dict(requested),
"payload": payload,
"changes": changes,
"unchanged_fields": sorted(unchanged),
"no_op": no_op,
"preserved_before": preserved_snapshot(current),
"reasons": (
[
"requested "
+ ", ".join(sorted(unchanged))
+ " already match the issue's current content; no edit was sent"
]
if no_op
else []
),
"safe_next_action": (
(
f"Re-read issue #{number} and call gitea_edit_issue only with "
"content that differs, or drop the call if the issue is already "
"correct."
)
if no_op
else ""
),
}
def verify_issue_edit(
observed: Mapping[str, Any],
*,
plan: Mapping[str, Any],
) -> dict[str, Any]:
"""Read-after-write proof for an applied issue edit.
Fails closed on two distinct defects: an edited field whose stored value is
not what was requested, and an untouched field that moved anyway.
"""
requested = dict(plan.get("requested") or {})
number = plan.get("issue_number")
applied: dict[str, Any] = {}
mismatches: list[dict[str, Any]] = []
for field, expected in requested.items():
actual = observed.get(field)
if field == "body":
actual = actual or ""
applied[field] = actual
if actual != expected:
mismatches.append(
{"field": field, "expected": expected, "observed": actual}
)
before = dict(plan.get("preserved_before") or {})
after = preserved_snapshot(observed)
preserved_changed: list[dict[str, Any]] = [
{"field": field, "before": before.get(field), "after": after.get(field)}
for field in PRESERVED_FIELDS
if before.get(field) != after.get(field)
]
reasons: list[str] = []
for entry in mismatches:
reasons.append(
f"{entry['field']} was not applied: requested "
f"{entry['expected']!r} but the issue stores {entry['observed']!r}"
)
for entry in preserved_changed:
reasons.append(
f"{entry['field']} changed during the edit: {entry['before']!r} "
f"became {entry['after']!r}; gitea_edit_issue must leave it alone"
)
verified = not reasons
return {
"verified": verified,
"applied": applied,
"mismatches": mismatches,
"preserved_before": before,
"preserved_after": after,
"preserved_changed": preserved_changed,
"preserved_intact": not preserved_changed,
"reasons": reasons,
"safe_next_action": (
""
if verified
else (
f"Re-read issue #{number} with gitea_view_issue and reconcile it "
"before treating the edit as applied. Do not retry blindly — the "
"stored content does not match what was requested."
)
),
}
+129
View File
@@ -16,9 +16,14 @@ import issue_acceptance_gate
import issue_lock_provenance
import merger_lease_adoption
import reviewer_handoff_consistency
import runtime_recovery_guard
import thread_state_ledger_validator
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
from self_propagating_handoff import (
HANDOFF_HEADING as SELF_PROPAGATING_HANDOFF_HEADING,
assess_final_report_self_propagating_handoff,
)
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
@@ -728,6 +733,65 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
)
_MUTATION_ACCOUNTING_PATTERNS = {
"local_failed_attempts": re.compile(
r"local\s+failed\s+attempts\s*:\s*(\d+)", re.IGNORECASE
),
"blocked_api_attempts": re.compile(
r"blocked\s+api\s+attempts\s*:\s*(\d+)", re.IGNORECASE
),
"successful_server_mutations": re.compile(
r"successful\s+server(?:[-\s]side)?\s+mutations\s*:\s*(\d+)", re.IGNORECASE
),
}
_READBACK_VERIFIED_PATTERN = re.compile(
r"read[-\s]?after[-\s]?write\s+verified\s*:\s*(yes|true)", re.IGNORECASE
)
def _rule_shared_mutation_budget_accounting(
report_text: str,
*,
mutation_attempt_ledger: list[dict] | None = None,
) -> list[dict[str, str]]:
"""#617: mutation budget counts server-side changes only.
No-op unless the session supplies an attempt ledger. When it does, the
report's three attempt categories must match the ledger exactly, so a
pre-API validator rejection can never be reported as a Gitea mutation and
a real mutation can never be hidden.
"""
if mutation_attempt_ledger is None:
return []
from mutation_budget_classifier import assess_final_report_mutation_accounting
text = report_text or ""
claimed: dict[str, Any] = {}
for field, pattern in _MUTATION_ACCOUNTING_PATTERNS.items():
match = pattern.search(text)
if match:
claimed[field] = int(match.group(1))
if _READBACK_VERIFIED_PATTERN.search(text):
claimed["readback_verified"] = True
result = assess_final_report_mutation_accounting(claimed, mutation_attempt_ledger)
if result.get("valid"):
return []
return _findings_from_reasons(
"shared.mutation_budget_accounting",
result.get("reasons") or [],
field="Mutation accounting",
severity="block",
safe_next_action=(
"report 'Local failed attempts:', 'Blocked API attempts:', and "
"'Successful server-side mutations:' with counts matching the "
"attempt ledger; pre-API rejections are not Gitea mutations"
),
)
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
from conflict_fix_classification import (
assess_conflict_fix_classification_final_report,
@@ -1564,6 +1628,21 @@ def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, st
)
def _rule_shared_self_propagating_handoff(report_text: str) -> list[dict[str, str]]:
"""#626: a report that adopts the handoff protocol must complete it."""
result = assess_final_report_self_propagating_handoff(report_text)
if not result.get("applicable") or not result.get("block"):
return []
return _findings_from_reasons(
"shared.self_propagating_handoff",
result.get("reasons") or ["incomplete canonical handoff"],
field=SELF_PROPAGATING_HANDOFF_HEADING,
severity="block",
safe_next_action=result.get("safe_next_action")
or "complete every canonical handoff field before posting",
)
_SHARED_ISSUE_LOCK_RULES = (
_rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override,
@@ -1584,13 +1663,24 @@ _SHARED_CANONICAL_COMMENT_RULES = (
_rule_shared_canonical_comment_post_claim,
)
_SHARED_MUTATION_BUDGET_RULES = (
_rule_shared_mutation_budget_accounting,
)
# #626: enforced for every task kind that can continue the workflow chain.
_SHARED_SELF_PROPAGATING_HANDOFF_RULES = (
_rule_shared_self_propagating_handoff,
)
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_MUTATION_BUDGET_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
@@ -1620,6 +1710,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_stale_head_proof,
],
"merge_pr": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
@@ -1631,11 +1722,13 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_stale_head_proof,
],
"reconcile_already_landed": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_reconcile_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_MUTATION_BUDGET_RULES,
*_SHARED_ISSUE_LOCK_RULES,
*_SHARED_CLEANUP_PROOF_RULES,
_rule_reconcile_stale_author_fields,
@@ -1650,20 +1743,24 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_audit_reconciliation_boundary,
],
"author_issue": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_MUTATION_BUDGET_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
],
"work_issue": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_MUTATION_BUDGET_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_shared_issue_acceptance_gate,
_rule_reviewer_vague_mutations_none,
@@ -1672,28 +1769,34 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_worktree_cleanup_audit_proof,
],
"issue_filing": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_MUTATION_BUDGET_RULES,
*_SHARED_ISSUE_LOCK_RULES,
],
"inventory": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_MUTATION_BUDGET_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_pagination_proof,
],
"issue_selection": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_MUTATION_BUDGET_RULES,
*_SHARED_ISSUE_LOCK_RULES,
],
# Controller issue closure (#529): a closure report must not bury an
@@ -1701,6 +1804,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
# Kept intentionally narrow so a closure pre-check does not demand the
# full reviewer/author handoff schema.
"controller_close": [
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
_rule_reviewer_premerge_baseline_proof,
],
}
@@ -1766,6 +1870,8 @@ def assess_final_report_validator(
session_pr_opened: bool = False,
validation_session: dict | None = None,
reconciler_close_lock: dict | None = None,
mutation_attempt_ledger: list[dict] | None = None,
runtime_recovery_marker: dict | None = None,
) -> dict[str, Any]:
"""Validate final-report text against task-specific proof rules (#327).
@@ -1804,6 +1910,28 @@ def assess_final_report_validator(
action_log = sanitized_action_log
findings.extend(action_log_findings)
# #630 scope item 4: while a manual daemon-kill contamination marker is
# live, the report must surface it and must not claim a clean session.
if runtime_recovery_marker:
runtime_recovery = runtime_recovery_guard.assess_final_report_claim(
report_text,
runtime_recovery_marker,
)
checks["runtime_recovery_contamination"] = runtime_recovery
if runtime_recovery.get("block"):
findings.extend(
_findings_from_reasons(
"shared.runtime_recovery_contamination",
runtime_recovery.get("reasons") or [],
field="Runtime recovery",
severity="block",
safe_next_action=(
"state the manual daemon kill and the pending reconciler "
"audit in the report; remove any clean-session claim"
),
)
)
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
checks["issue_filing"] = assess_issue_filing_final_report(
report_text,
@@ -1829,6 +1957,7 @@ def assess_final_report_validator(
"session_pr_opened": session_pr_opened,
"validation_session": validation_session,
"reconciler_close_lock": reconciler_close_lock,
"mutation_attempt_ledger": mutation_attempt_ledger,
}
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
+3036 -221
View File
File diff suppressed because it is too large Load Diff
+143
View File
@@ -484,6 +484,78 @@ def build_gitea_issue_body(inc: NormalizedIncident) -> str:
return "\n".join(lines)
def incident_recurred(
existing: dict[str, Any], inc: NormalizedIncident
) -> tuple[bool, str]:
"""Did new provider events arrive since the existing link was last synced?
AC4 asks for a recurrence comment when events *continue*, so a scan that
observes no new events must stay silent instead of re-posting the same
state on every pass.
"""
old_count = existing.get("event_count")
new_count = inc.event_count
if (
isinstance(old_count, int)
and isinstance(new_count, int)
and new_count > old_count
):
return True, f"event_count advanced {old_count} -> {new_count}"
old_seen = str(existing.get("last_seen") or "").strip()
new_seen = str(inc.last_seen or "").strip()
if new_seen and new_seen != old_seen:
return True, f"last_seen advanced '{old_seen}' -> '{new_seen}'"
return False, "no new provider events since the last sync"
def build_recurrence_comment_body(
inc: NormalizedIncident, existing: dict[str, Any], *, reason: str = ""
) -> str:
"""Sanitized recurrence comment for an already-linked Gitea issue (AC4).
Uses the same redaction path as :func:`build_gitea_issue_body`; never
carries tokens, raw paths, or session state.
"""
lines = [
"## Observability incident recurrence (bridge #612)",
"",
"<!-- mcp-incident-bridge:recurrence:v1 -->",
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
"",
f"Continued `{inc.provider}` events for this linked incident.",
"",
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
]
if inc.provider_short_id:
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
if inc.provider_permalink:
lines.append(f"- **provider_url:** {inc.provider_permalink}")
lines.extend(
[
f"- **event_count:** `{existing.get('event_count')}` -> "
f"`{inc.event_count if inc.event_count is not None else ''}`",
f"- **first_seen:** `{inc.first_seen or ''}`",
f"- **last_seen:** `{inc.last_seen or ''}`",
f"- **environment:** `{inc.environment or ''}`",
f"- **severity:** `{inc.severity or ''}`",
f"- **culprit:** `{inc.culprit or ''}`",
f"- **status:** `{inc.status}`",
f"- **recurrence_basis:** `{reason}`",
"",
"### Latest summary",
"",
redact_text(inc.summary) or "(no summary)",
"",
"### Canonical next action",
"",
"Author: this incident is still firing — investigate under the "
"normal Gitea workflow. This comment records observability "
"recurrence only and changes no workflow state.",
]
)
return "\n".join(lines)
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
"""Fail closed if existing link targets a different Gitea issue/repo."""
eg_org = str(existing.get("gitea_org") or "")
@@ -514,6 +586,9 @@ def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | N
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
CommentIssueFn = Callable[[int, str, str, str], dict[str, Any]]
# comment_issue_fn(gitea_issue_number, body, gitea_org, gitea_repo) -> {"success": bool, ...}
def reconcile_incident(
db: ControlPlaneDB | None,
@@ -523,6 +598,7 @@ def reconcile_incident(
mapping: ProjectMapping | None = None,
apply: bool = False,
create_issue_fn: CreateIssueFn | None = None,
comment_issue_fn: CommentIssueFn | None = None,
force_gitea_issue_number: int | None = None,
) -> dict[str, Any]:
"""Reconcile one observation into incident_links + optional Gitea issue.
@@ -531,6 +607,11 @@ def reconcile_incident(
*apply=True*: upsert link; create Gitea issue when none linked (requires
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
When an existing link is reused and the provider reports *new* events,
``comment_issue_fn`` posts a sanitized recurrence comment on the linked
Gitea issue (AC4). Dry runs never comment, and a missing
``comment_issue_fn`` withholds the comment without failing the link.
Never creates control-plane ``work_items`` for raw incidents.
"""
base: dict[str, Any] = {
@@ -549,6 +630,7 @@ def reconcile_incident(
"gitea_issue": None,
"action": None,
"mapping": None,
"recurrence_comment": None,
"substrate": "control_plane_db.incident_links",
"durable_work_system": "gitea_issues",
}
@@ -652,10 +734,14 @@ def reconcile_incident(
# --- apply path ---
issue_number: int | None = None
created = False
recurrence: tuple[bool, str] | None = None
if existing:
issue_number = int(existing["gitea_issue_number"])
action = "updated_existing_link"
outcome = OUTCOME_UPDATED
# Compare against the pre-upsert link row: the upsert below overwrites
# event_count/last_seen, which would erase the recurrence signal.
recurrence = incident_recurred(existing, inc)
elif force_gitea_issue_number is not None:
issue_number = int(force_gitea_issue_number)
action = "link_explicit_issue"
@@ -729,6 +815,63 @@ def reconcile_incident(
}
return base
# AC4: continued provider events post a recurrence comment on the linked
# Gitea issue. The durable incident_links row is already written above, so
# a comment failure never rolls back or blocks the mapping — the next scan
# retries while the link stays authoritative.
if outcome == OUTCOME_UPDATED and recurrence is not None:
recurred, why = recurrence
if not recurred:
base["recurrence_comment"] = {"posted": False, "reason": why}
elif comment_issue_fn is None:
base["recurrence_comment"] = {
"posted": False,
"reason": (
"no comment_issue_fn supplied; recurrence comment withheld "
"(link remains durable)"
),
"recurrence_basis": why,
}
else:
try:
comment_res = comment_issue_fn(
issue_number,
build_recurrence_comment_body(inc, existing, reason=why),
inc.gitea_org,
inc.gitea_repo,
)
except Exception as exc: # noqa: BLE001 - never break the link write
base["recurrence_comment"] = {
"posted": False,
"reason": (
f"recurrence comment failed: {redact_text(exc)} "
"(link remains durable)"
),
"recurrence_basis": why,
}
else:
posted = (
bool(comment_res.get("success"))
if isinstance(comment_res, dict)
else bool(comment_res)
)
base["recurrence_comment"] = {
"posted": posted,
"recurrence_basis": why,
"gitea_issue_number": issue_number,
"comment_id": (
comment_res.get("comment_id")
if isinstance(comment_res, dict)
else None
),
}
if posted:
base["gitea_mutated"] = True
elif isinstance(comment_res, dict):
base["recurrence_comment"]["reasons"] = [
redact_text(r) for r in (comment_res.get("reasons") or [])
]
base["success"] = True
base["performed"] = True
base["db_mutated"] = True
+9
View File
@@ -85,6 +85,15 @@ def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
return re.search(pattern, name) is not None
def branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
"""Public accessor for the exact issue-marker match (#753).
Dead-session lock recovery needs the same word-boundary matcher to detect
ambiguous branch claims, so it is exposed rather than reached into.
"""
return _branch_carries_issue_marker(branch_name, issue_number)
def assess_own_branch_adoption(
*,
issue_number: int,
+835
View File
@@ -0,0 +1,835 @@
"""Dead-session author issue-lock recovery (#753).
A durable author issue lock records the PID of the MCP session that took it.
When that process exits, ``issue_lock_store.assess_lock_freshness`` classifies
the lock as ``stale`` (``live=False``) even while its lease is still within TTL,
so every ownership check that requires a *live* lock fails closed.
Re-taking the lock through ``gitea_lock_issue`` is unreachable for real work:
``issue_lock_worktree.assess_issue_lock_worktree`` demands the worktree be
base-equivalent to ``master``/``main``/``dev``, and a branch that already
carries commits is ahead of its base by construction. The existing
``assess_expired_lock_reclaim`` affordance does not apply either, because
``assess_same_issue_lease_conflict`` only consults it once the lease has
*expired* a dead PID under an unexpired lease never reaches it.
This module is the pure evidence assessor for that one narrow case. It grants
recovery only when every element of durable ownership still matches exactly and
the recorded process is demonstrably dead. It never trusts caller assertions:
every field is compared against durable lock state or live observation supplied
by the caller. It performs no mutation and no network I/O.
Recovery deliberately does **not** relax base-equivalence for brand-new issue
claims only for a lock whose own prior record already proves the branch,
worktree, head, and author.
#768 extends the head requirement from strict equality to "equal, or a strict
descendant". Equality alone made remediation after a session death unreachable:
recovery needs a clean worktree, the only sanctioned way to clean one without
discarding work is to commit, and committing advances the head past the value
recorded at lock time. A commit that strictly descends from the recorded head,
on the same branch, in the same worktree, by the same claimant, preserves
everything equality protected the recorded head is still reachable, still an
ancestor, still unmodified so it is accepted, and nothing else is. The
descendant fact is observed server-side by
``issue_lock_worktree.read_head_ancestry`` and handed in as ``head_ancestry``;
no caller can assert it.
#772 adds the remaining uncovered quadrant: a claim that was never published at
all. Two recovery modes now exist, and they require different evidence because
they are answering the same question against different available facts:
``published_owning_pr``
The branch exists on the remote. Ownership is proven by comparing the local
head against the remote/PR head equal (#753) or a strict descendant
(#768). This is the pre-existing behavior and is unchanged.
``unpublished_claim``
The branch is absent from the remote and no PR claims it, so there is no
head to compare against; that absence is the defining fact, not a degraded
published case. Ownership is instead proven by the durable lock record
(issue, branch, worktree, claimant, profile, dead PID) plus the local HEAD
strictly descending from the base the branch was cut from, observed
server-side by ``issue_lock_worktree.read_recorded_base`` and re-checked
through ``base_ancestry``.
They cannot share one head-comparison implementation: the published path's
comparison target does not exist in the unpublished case, and inventing one
(defaulting to the base, say) would silently weaken the published path from
"matches what was actually pushed" to "descends from some base". The modes are
therefore selected by observed publication state and never by a caller and
critically, the absence of a remote head is never itself treated as permission:
every identity, profile, branch, worktree, cleanliness, liveness, and competing
-claim check still applies in full.
"""
from __future__ import annotations
import os
from typing import Any, Iterable, Mapping, Sequence
from issue_lock_store import is_process_alive
from reviewer_worktree import parse_dirty_tracked_files
# Outcome values
RECOVERY_SANCTIONED = "RECOVERY_SANCTIONED"
NO_CANDIDATE = "NO_CANDIDATE"
REFUSED = "REFUSED"
# Durable fields a lock must carry before it can be considered at all.
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
# How the clean local head relates to the head recorded at lock time (#768).
HEAD_RELATION_EQUAL = "equal"
HEAD_RELATION_STRICT_DESCENDANT = "strict_descendant"
# #772: an unpublished claim has no recorded head to compare against at all, so
# its head is measured against the base the branch was cut from instead.
HEAD_RELATION_DESCENDS_FROM_BASE = "descends_from_recorded_base"
# Which body of evidence a recovery was decided on (#772 AC10). These are not
# interchangeable: a published claim proves ownership against a remote/PR head,
# an unpublished one against the recorded base plus durable lock state. They
# cannot share a single head-comparison implementation because the unpublished
# case has no head to compare — that absence is the defining fact, not a
# degraded version of the published case.
RECOVERY_MODE_PUBLISHED_OWNING_PR = "published_owning_pr"
RECOVERY_MODE_UNPUBLISHED_CLAIM = "unpublished_claim"
def _same_realpath(left: str | None, right: str | None) -> bool:
if not left or not right:
return False
try:
return os.path.realpath(left) == os.path.realpath(right)
except OSError:
return left == right
def _text(value: Any) -> str:
return str(value or "").strip()
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
claimant = lock.get("claimant")
if not isinstance(claimant, Mapping):
lease = lock.get("work_lease")
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
return dict(claimant) if isinstance(claimant, Mapping) else {}
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
pid = lock.get("session_pid")
if pid is None:
pid = lock.get("pid")
return pid
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
"""Names of durable fields that are missing or unusable."""
missing: list[str] = []
for field in REQUIRED_LOCK_FIELDS:
if not _text(lock.get(field)):
missing.append(field)
pid = _recorded_pid(lock)
if pid is None or _text(pid) == "":
missing.append("session_pid/pid")
else:
try:
if int(pid) <= 0:
missing.append("session_pid/pid")
except (TypeError, ValueError):
missing.append("session_pid/pid")
return missing
def _assess_strict_descendant(
head_ancestry: Mapping[str, Any] | None,
*,
recorded_head: str,
local_head: str,
) -> tuple[bool, list[str]]:
"""Is ``local_head`` a proven strict descendant of ``recorded_head`` (#768)?
``head_ancestry`` is the server-side git observation from
``issue_lock_worktree.read_head_ancestry``. Its own ``ancestor_sha`` /
``descendant_sha`` are re-checked against the heads this assessment is
actually reasoning about, so a probe taken for some other pair of commits
stale, mismatched, or hand-built can never authorize a waiver.
Returns ``(proven, notes)``. Notes name the exact missing element so a
refused caller sees why, never a bare "unproven".
"""
if not isinstance(head_ancestry, Mapping):
return False, [
"no server-derived ancestry observation was available; a local head "
"that differs from the recorded head cannot be accepted"
]
notes: list[str] = []
probe_ancestor = _text(head_ancestry.get("ancestor_sha"))
probe_descendant = _text(head_ancestry.get("descendant_sha"))
if probe_ancestor != recorded_head or probe_descendant != local_head:
return False, [
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
f"{probe_descendant or 'unknown'}, not the heads under assessment "
f"({recorded_head} -> {local_head})"
]
if not head_ancestry.get("probe_ok"):
notes.extend(
list(head_ancestry.get("reasons") or [])
or ["ancestry probe did not complete; ancestry unproven"]
)
return False, notes
if not head_ancestry.get("ancestor_present"):
return False, [
f"recorded head {recorded_head} is no longer reachable; a rewritten "
"or force-moved head cannot be recovered"
]
if not head_ancestry.get("is_strict_descendant"):
notes.extend(
list(head_ancestry.get("reasons") or [])
or [
f"local head {local_head} is not a strict descendant of the "
f"recorded head {recorded_head}"
]
)
return False, notes
proof = _text(head_ancestry.get("proof")) or (
f"{recorded_head} is an ancestor of {local_head}"
)
return True, [
f"local head {local_head} strictly descends from recorded head "
f"{recorded_head} ({proof})"
]
def _assess_base_descendancy(
base_ancestry: Mapping[str, Any] | None,
*,
recorded_base: str,
local_head: str,
) -> tuple[bool, list[str]]:
"""Is ``local_head`` a proven strict descendant of ``recorded_base`` (#772)?
The unpublished-claim analogue of ``_assess_strict_descendant``. The
comparison target is the base the branch was cut from observed server-side
by ``issue_lock_worktree.read_recorded_base`` rather than a remote or PR
head, because an unpublished claim has neither.
The probe's own endpoints are re-checked against the values under
assessment, so an observation taken for some other pair of commits cannot
authorize recovery. Equality is refused: a HEAD that merely equals its base
carries no committed work, and that is the ordinary base-equivalent case the
normal lock path already handles.
"""
if not isinstance(base_ancestry, Mapping):
return False, [
"no server-derived ancestry observation was available; an "
"unpublished claim cannot be recovered without proving its HEAD "
"descends from the recorded base"
]
probe_ancestor = _text(base_ancestry.get("ancestor_sha"))
probe_descendant = _text(base_ancestry.get("descendant_sha"))
if probe_ancestor != recorded_base or probe_descendant != local_head:
return False, [
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
f"{probe_descendant or 'unknown'}, not the commits under assessment "
f"({recorded_base} -> {local_head})"
]
if not base_ancestry.get("probe_ok"):
return False, (
list(base_ancestry.get("reasons") or [])
or ["ancestry probe did not complete; ancestry unproven"]
)
if not base_ancestry.get("ancestor_present"):
return False, [
f"recorded base {recorded_base} is no longer reachable; a rewritten "
"or force-moved base cannot be recovered"
]
if not base_ancestry.get("is_strict_descendant"):
return False, (
list(base_ancestry.get("reasons") or [])
or [
f"local head {local_head} is not a strict descendant of the "
f"recorded base {recorded_base}"
]
)
proof = _text(base_ancestry.get("proof")) or (
f"{recorded_base} is an ancestor of {local_head}"
)
return True, [
f"local head {local_head} strictly descends from recorded base "
f"{recorded_base} ({proof})"
]
def assess_dead_session_lock_recovery(
existing_lock: Mapping[str, Any] | None,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
current_branch: str | None,
porcelain_status: str,
head_sha: str | None,
remote_head_sha: str | None,
pr_head_sha: str | None = None,
pr_number: int | None = None,
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
candidate_branches: Iterable[str] | None = None,
current_pid: int | None = None,
head_ancestry: Mapping[str, Any] | None = None,
remote_branch_exists: bool | None = None,
recorded_base_sha: str | None = None,
base_ancestry: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Decide whether a dead-session author lock may be natively recovered.
Returns a dict with ``recovery_sanctioned`` (bool), ``outcome``, ``reasons``
(why it was refused, or the positive proof when sanctioned), and
``evidence`` (a redaction-safe record for auditing).
``NO_CANDIDATE`` means no recovery was attempted at all there is no
existing lock, or the lock does not describe this issue. The caller must
treat that exactly as it treated the pre-#753 world. ``REFUSED`` means a
candidate existed but the evidence did not agree; the caller fails closed.
"""
reasons: list[str] = []
evidence: dict[str, Any] = {
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": worktree_path,
"remote": remote,
"org": org,
"repo": repo,
}
if not existing_lock:
return _result(
NO_CANDIDATE, False, ["no existing durable lock for this issue"], evidence
)
lock = dict(existing_lock)
# ── Candidate identification ────────────────────────────────────────────
# Recovery only ever applies to a lock that already claims THIS issue.
# Anything else is not a recovery candidate and must not be reinterpreted.
if lock.get("issue_number") != issue_number:
return _result(
NO_CANDIDATE,
False,
[
f"existing lock targets issue #{lock.get('issue_number')}, "
f"not #{issue_number}; not a recovery candidate"
],
evidence,
)
# A malformed/incomplete durable record can never prove ownership.
missing = _malformed_reasons(lock)
if missing:
return _result(
REFUSED,
False,
[
"durable lock record is incomplete and cannot prove ownership "
f"(missing/unusable: {', '.join(missing)})"
],
evidence,
)
recorded_pid = _recorded_pid(lock)
evidence["prior_session_pid"] = recorded_pid
evidence["replacement_session_pid"] = (
current_pid if current_pid is not None else os.getpid()
)
# ── Repository scope ────────────────────────────────────────────────────
for field, expected in (("remote", remote), ("org", org), ("repo", repo)):
actual = _text(lock.get(field))
if actual != _text(expected):
reasons.append(
f"lock {field} '{actual}' does not match requested '{_text(expected)}'"
)
# ── Branch identity ─────────────────────────────────────────────────────
locked_branch = _text(lock.get("branch_name"))
if locked_branch != _text(branch_name):
reasons.append(
f"lock branch '{locked_branch}' does not match requested "
f"'{_text(branch_name)}'"
)
evidence["locked_branch"] = locked_branch
# The worktree must actually be sitting on the locked branch. Without this
# a clean worktree parked elsewhere could stand in for the real work.
checked_out = _text(current_branch)
if not checked_out:
reasons.append(
"worktree is not on a named branch (detached HEAD); locked-branch "
"occupancy could not be proven"
)
elif checked_out != locked_branch:
reasons.append(
f"worktree is on branch '{checked_out}', not the locked branch "
f"'{locked_branch}'"
)
# ── Worktree identity ───────────────────────────────────────────────────
locked_worktree = _text(lock.get("worktree_path"))
if not _same_realpath(locked_worktree, worktree_path):
reasons.append(
f"lock worktree '{locked_worktree}' does not match declared "
f"'{_text(worktree_path)}'"
)
evidence["locked_worktree_path"] = locked_worktree
# ── Cleanliness (never waived) ──────────────────────────────────────────
dirty_files = parse_dirty_tracked_files(porcelain_status)
if dirty_files:
reasons.append(
"worktree has tracked local edits; recovery requires a clean "
f"worktree (dirty files: {', '.join(dirty_files)})"
)
evidence["dirty_files"] = dirty_files
# ── Head agreement: local is the recorded head, or strictly descends it ──
# The recorded head is what the remote branch still carries. A local head
# equal to it is the #753 case. A local head that strictly descends from it
# is the #768 case: the author committed remediation, which is the only way
# to reach the clean worktree recovery itself demands.
local_head = _text(head_sha)
remote_head = _text(remote_head_sha)
recorded_base = _text(recorded_base_sha)
head_relation: str | None = None
ancestry_proof: str | None = None
if not local_head:
reasons.append("local head SHA could not be determined")
# #772: which body of evidence applies is decided by observed publication
# state, never by a caller. ``remote_branch_exists is False`` is a positive
# server-side observation that the branch is absent from the remote — it is
# not the same as "the head lookup failed", which must still fail closed.
unpublished = remote_branch_exists is False and not remote_head
recovery_mode = (
RECOVERY_MODE_UNPUBLISHED_CLAIM if unpublished
else RECOVERY_MODE_PUBLISHED_OWNING_PR
)
evidence["recovery_mode"] = recovery_mode
evidence["remote_branch_exists"] = remote_branch_exists
if unpublished:
# No remote branch: ownership is measured against the recorded base.
# An open PR here is contradictory — a PR cannot exist without a remote
# branch — so it is a mismatch, never a thing to reconcile.
if _text(pr_head_sha) or pr_number is not None:
reasons.append(
f"branch '{locked_branch}' is absent from the remote yet PR "
f"#{pr_number} claims it; publication state is contradictory"
)
if not recorded_base:
reasons.append(
f"recorded base for branch '{locked_branch}' could not be "
"determined; an unpublished claim cannot be recovered without it"
)
if local_head and recorded_base:
descends, notes = _assess_base_descendancy(
base_ancestry,
recorded_base=recorded_base,
local_head=local_head,
)
if descends:
head_relation = HEAD_RELATION_DESCENDS_FROM_BASE
ancestry_proof = notes[0] if notes else None
else:
reasons.extend(notes)
else:
if not remote_head:
reasons.append(
f"remote head for branch '{locked_branch}' could not be determined"
)
if local_head and remote_head:
if local_head == remote_head:
head_relation = HEAD_RELATION_EQUAL
else:
descends, notes = _assess_strict_descendant(
head_ancestry,
recorded_head=remote_head,
local_head=local_head,
)
if descends:
head_relation = HEAD_RELATION_STRICT_DESCENDANT
ancestry_proof = notes[0] if notes else None
else:
reasons.append(
f"local head {local_head} does not match remote branch head "
f"{remote_head}"
)
reasons.extend(notes)
evidence["recorded_base"] = recorded_base or None
evidence["local_head"] = local_head or None
evidence["remote_head"] = remote_head or None
# ``recorded_head`` is the head recovery is being measured against;
# ``accepted_head`` is the head this recovery actually adopts. They differ
# only in the descendant case, and downstream gates need both (#768 AC2/AC7).
evidence["recorded_head"] = remote_head or None
evidence["accepted_head"] = local_head or None
evidence["head_relation"] = head_relation
evidence["ancestry_proof"] = ancestry_proof
pr_head = _text(pr_head_sha)
if pr_head:
evidence["pr_head"] = pr_head
evidence["pr_number"] = pr_number
# In unpublished mode the presence of any PR was already refused above as
# contradictory; re-stating it as a head mismatch would only obscure why.
if not unpublished and local_head and pr_head != local_head:
# A descendant recovery has not been published yet, so the open PR
# legitimately still points at the recorded head. Any other
# disagreement is a real mismatch.
if not (
head_relation == HEAD_RELATION_STRICT_DESCENDANT
and remote_head
and pr_head == remote_head
):
reasons.append(
f"open PR #{pr_number} head {pr_head} does not match local head "
f"{local_head}"
)
# ── Author identity ─────────────────────────────────────────────────────
claimant = _lock_claimant(lock)
locked_identity = _text(claimant.get("username"))
locked_profile = _text(claimant.get("profile"))
evidence["locked_identity"] = locked_identity or None
evidence["locked_profile"] = locked_profile or None
if not locked_identity or not locked_profile:
reasons.append(
"durable lock does not record a claimant identity/profile; "
"author ownership could not be proven"
)
if not _text(identity) or not _text(profile):
reasons.append(
"active session identity/profile is unknown; author ownership "
"could not be proven"
)
if locked_identity and _text(identity) and locked_identity != _text(identity):
reasons.append(
f"lock claimant '{locked_identity}' does not match active identity "
f"'{_text(identity)}'"
)
if locked_profile and _text(profile) and locked_profile != _text(profile):
reasons.append(
f"lock profile '{locked_profile}' does not match active profile "
f"'{_text(profile)}'"
)
# ── The defining condition: the recorded owner must be dead ─────────────
prior_alive = is_process_alive(recorded_pid)
evidence["prior_pid_alive"] = prior_alive
if prior_alive:
reasons.append(
f"prior owner pid {recorded_pid} is still alive; this is not a "
"dead-session recovery"
)
if current_pid is not None and recorded_pid is not None:
try:
if int(recorded_pid) == int(current_pid):
reasons.append(
"recorded pid is the current session; nothing to recover"
)
except (TypeError, ValueError):
pass
# ── Competing ownership ─────────────────────────────────────────────────
competing: list[dict[str, Any]] = []
for entry in competing_live_locks or ():
if not isinstance(entry, Mapping):
continue
same_issue = entry.get("issue_number") == issue_number
same_branch = _text(entry.get("branch_name")) == locked_branch
if not (same_issue or same_branch):
continue
# The lock we are recovering is not competition with itself.
if (
same_issue
and same_branch
and _same_realpath(_text(entry.get("worktree_path")), worktree_path)
):
continue
competing.append(
{
"issue_number": entry.get("issue_number"),
"branch_name": entry.get("branch_name"),
"worktree_path": entry.get("worktree_path"),
"pid": entry.get("pid"),
}
)
if competing:
described = ", ".join(
f"issue #{c['issue_number']} branch '{c['branch_name']}'" for c in competing
)
reasons.append(f"competing live lock or lease exists ({described})")
evidence["competing_live_locks"] = competing
# ── Ambiguous branch claims ─────────────────────────────────────────────
others = [
name
for name in (candidate_branches or ())
if _text(name) and _text(name) != locked_branch
]
if others:
reasons.append(
"multiple branches claim this issue "
f"({', '.join(sorted(set(others)))}); ownership is ambiguous"
)
evidence["other_candidate_branches"] = sorted(set(others))
if reasons:
return _result(REFUSED, False, reasons, evidence)
# No disposition may be granted without a proven head relation. Every path
# above that leaves it unset also records a reason, so this is a belt-and-
# braces guard against a future path forgetting one (#772 AC4).
if head_relation is None:
return _result(
REFUSED,
False,
["head relation to the recorded head or base was never proven"],
evidence,
)
proof = [
f"durable lock for issue #{issue_number} matches branch "
f"'{locked_branch}', worktree '{locked_worktree}', head {local_head}, "
f"and claimant '{locked_identity}'; recorded pid {recorded_pid} is dead"
]
if recovery_mode == RECOVERY_MODE_UNPUBLISHED_CLAIM:
proof.append(
f"branch '{locked_branch}' has no remote head and no open PR; "
f"ownership proven against recorded base {recorded_base}"
)
if (
head_relation
in (HEAD_RELATION_STRICT_DESCENDANT, HEAD_RELATION_DESCENDS_FROM_BASE)
and ancestry_proof
):
proof.append(ancestry_proof)
return _result(RECOVERY_SANCTIONED, True, proof, evidence)
def _result(
outcome: str,
sanctioned: bool,
reasons: list[str],
evidence: dict[str, Any],
) -> dict[str, Any]:
return {
"outcome": outcome,
"recovery_sanctioned": sanctioned,
"is_candidate": outcome != NO_CANDIDATE,
"reasons": reasons,
"evidence": evidence,
}
def owning_pr_recovery_evidence(
assessment: Mapping[str, Any] | None,
) -> dict[str, Any] | None:
"""Server-derived proof of the open PR a sanctioned recovery already owns (#755).
A dead-session recovery is, by construction, recovery of work that already
has an open PR so the duplicate-work gate's linked-open-PR blocker would
otherwise discard every sanctioned recovery. This distils the completed
assessment into the minimum evidence that gate needs to tell "the PR this
lock already owns" apart from "a competing duplicate PR".
Returns ``None`` unless recovery was actually granted and the assessment's
own evidence names exactly one owning PR whose head agrees with the heads
the assessor accepted. Nothing here is caller-supplied: every field is
copied from evidence the assessor built out of durable lock state plus live
git/Gitea observation, so a caller cannot manufacture an exemption.
#768: a descendant recovery carries two heads. ``head_sha`` stays the head
the open PR currently shows (the recorded head, since the remediation is not
published yet) and ``accepted_head`` is the local descendant that
publication will move it to. Downstream gates accept either, so the
exemption survives the very push it exists to permit.
"""
if not isinstance(assessment, Mapping):
return None
if assessment.get("outcome") != RECOVERY_SANCTIONED:
return None
if not assessment.get("recovery_sanctioned"):
return None
evidence = assessment.get("evidence") or {}
branch_name = _text(evidence.get("locked_branch"))
pr_head = _text(evidence.get("pr_head"))
local_head = _text(evidence.get("local_head"))
remote_head = _text(evidence.get("remote_head"))
recorded_head = _text(evidence.get("recorded_head")) or remote_head
accepted_head = _text(evidence.get("accepted_head")) or local_head
relation = _text(evidence.get("head_relation")) or HEAD_RELATION_EQUAL
raw_pr_number = evidence.get("pr_number")
if raw_pr_number is None or not branch_name or not pr_head:
return None
# The assessor already required these to agree. Re-check, so a truncated or
# hand-built evidence map can never authorize an exemption.
if relation == HEAD_RELATION_EQUAL:
if pr_head != local_head or pr_head != remote_head:
return None
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
# The PR must still be at the recorded head, and the accepted head must
# actually be a different commit — otherwise this is not a descendant.
if not recorded_head or pr_head != recorded_head:
return None
if not accepted_head or accepted_head == recorded_head:
return None
if accepted_head != local_head:
return None
else:
return None
try:
pr_number = int(raw_pr_number)
issue_number = int(evidence.get("issue_number"))
except (TypeError, ValueError):
return None
return {
"issue_number": issue_number,
"pr_number": pr_number,
"branch_name": branch_name,
"head_sha": pr_head,
"recorded_head": recorded_head or None,
"accepted_head": accepted_head or None,
"head_relation": relation,
}
def recovered_owning_pr_from_lock(
lock_record: Mapping[str, Any] | None,
) -> dict[str, Any] | None:
"""Rebuild owning-PR recovery evidence from a persisted lock (#768 AC2).
``gitea_lock_issue`` holds the live assessment only for the duration of the
lock call. The commit, push, create-PR, and duplicate-assessment gates run
later, in their own calls, and re-derive ownership from scratch so an open
PR that recovery already proved belongs to this author reappears there as
competing duplicate work.
This reads the same proof back out of the durable ``dead_session_recovery``
block that only the server writes, on a lock the caller must already own.
It is a re-read of server-derived state, not a new assertion: a caller that
could forge this could equally forge the lock file itself, which every other
ownership gate already treats as authoritative.
"""
if not isinstance(lock_record, Mapping):
return None
record = lock_record.get("dead_session_recovery")
if not isinstance(record, Mapping) or not record.get("recovered"):
return None
branch_name = _text(record.get("branch_name")) or _text(
lock_record.get("branch_name")
)
pr_head = _text(record.get("pr_head"))
recorded_head = _text(record.get("recorded_head")) or _text(
record.get("remote_head")
)
accepted_head = _text(record.get("accepted_head")) or _text(
record.get("local_head")
)
relation = _text(record.get("head_relation")) or HEAD_RELATION_EQUAL
raw_pr_number = record.get("pr_number")
raw_issue_number = lock_record.get("issue_number")
if raw_pr_number is None or raw_issue_number is None:
return None
if not branch_name or not pr_head:
return None
if relation == HEAD_RELATION_EQUAL:
if accepted_head and accepted_head != pr_head:
return None
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
if not recorded_head or pr_head != recorded_head:
return None
if not accepted_head or accepted_head == recorded_head:
return None
else:
return None
try:
pr_number = int(raw_pr_number)
issue_number = int(raw_issue_number)
except (TypeError, ValueError):
return None
return {
"issue_number": issue_number,
"pr_number": pr_number,
"branch_name": branch_name,
"head_sha": pr_head,
"recorded_head": recorded_head or None,
"accepted_head": accepted_head or None,
"head_relation": relation,
}
def build_recovery_record(
assessment: Mapping[str, Any],
*,
recovered_at: str,
) -> dict[str, Any]:
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6).
#768 AC7: a granted recovery records, atomically with the lock itself, both
session identities, the head it was measured against, the head it adopted,
how those two relate, and the ancestry proof so a descendant recovery can
be audited after the fact without re-running any probe.
"""
evidence = dict(assessment.get("evidence") or {})
return {
"recovered": True,
"reason": "owning MCP session exited; durable ownership evidence matched",
"recovered_at": recovered_at,
"prior_session_pid": evidence.get("prior_session_pid"),
"replacement_session_pid": evidence.get("replacement_session_pid"),
"prior_pid_alive": evidence.get("prior_pid_alive"),
"branch_name": evidence.get("locked_branch"),
"worktree_path": evidence.get("locked_worktree_path"),
"recovery_mode": evidence.get("recovery_mode"),
"remote_branch_exists": evidence.get("remote_branch_exists"),
"recorded_base": evidence.get("recorded_base"),
"local_head": evidence.get("local_head"),
"remote_head": evidence.get("remote_head"),
"recorded_head": evidence.get("recorded_head"),
"accepted_head": evidence.get("accepted_head"),
"head_relation": evidence.get("head_relation"),
"ancestry_proof": evidence.get("ancestry_proof"),
"pr_head": evidence.get("pr_head"),
"pr_number": evidence.get("pr_number"),
"identity": evidence.get("locked_identity"),
"profile": evidence.get("locked_profile"),
"proof": list(assessment.get("reasons") or []),
}
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
"""Single fail-closed message for a refused recovery attempt."""
reasons = list(assessment.get("reasons") or []) or [
"dead-session lock recovery evidence did not agree"
]
return (
"Dead-session issue-lock recovery refused: "
+ "; ".join(reasons)
+ " (fail closed)"
)
+45 -2
View File
@@ -148,8 +148,37 @@ def save_lock_file(path: str, data: dict[str, Any]) -> None:
pass
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
"""Persist a keyed lock and bind it to the current process session."""
def lock_generation(lock: dict[str, Any] | None) -> int:
"""Monotonic write counter for a durable lock record (#772 AC5).
Absent or unusable values read as ``0`` so a lock written before generations
existed still participates in compare-and-swap: its first recovery expects
``0`` and writes ``1``.
"""
if not isinstance(lock, dict):
return 0
try:
return int(lock.get("lock_generation") or 0)
except (TypeError, ValueError):
return 0
def bind_session_lock(
lock_data: dict[str, Any],
lock_dir: str | None = None,
*,
expected_generation: int | None = None,
) -> str:
"""Persist a keyed lock and bind it to the current process session.
``expected_generation`` turns the write into a compare-and-swap (#772 AC5).
Recovery decides it may take over a claim by reading the durable lock, but
that read and this write are separate steps; without a CAS two replacement
sessions can both observe the same dead owner, both pass assessment, and
both write the second silently clobbering the first. Passing the
generation observed at assessment time makes exactly one of them win: the
loser's expectation no longer matches and it fails closed.
"""
remote = str(lock_data.get("remote") or "")
org = str(lock_data.get("org") or "")
repo = str(lock_data.get("repo") or "")
@@ -194,6 +223,20 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
)
if lease_block:
raise RuntimeError(lease_block)
# #772 AC5: compare-and-swap inside the same critical section that
# already serializes writers, so the check and the write cannot be
# separated by another session's successful recovery.
current_generation = lock_generation(existing)
if (
expected_generation is not None
and current_generation != expected_generation
):
raise RuntimeError(
f"Issue #{issue_number} lock generation changed: expected "
f"{expected_generation}, found {current_generation}; another "
"session already recovered or replaced this claim (fail closed)"
)
record["lock_generation"] = current_generation + 1
save_lock_file(path, record)
save_lock_file(session_pointer_path(root), pointer)
except LockContentionError as exc:
+214 -3
View File
@@ -20,16 +20,208 @@ BASE_BRANCHES = frozenset({"master", "main", "dev"})
def resolve_author_worktree_path(
explicit: str | None,
project_root: str,
*,
session_lock_worktree: str | None = None,
) -> str:
"""Resolve the author worktree path for lock/PR gates."""
"""Resolve the author worktree path for lock/PR gates.
#618: prefer explicit path, then env, then the active issue lock worktree.
Does not invent a branches/ worktree. Falling back to *project_root* is
retained only for lock-time bootstrap when the process itself is already
under branches/ or no binding exists yet (callers still fail closed via
preflight / durable resolution before mutation).
"""
path = (explicit or "").strip()
if not path:
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
if not path:
path = (os.environ.get("GITEA_ACTIVE_WORKTREE") or "").strip()
if not path:
path = (session_lock_worktree or "").strip()
if not path:
path = project_root
return os.path.realpath(os.path.abspath(path))
def read_head_ancestry(
worktree_path: str,
*,
ancestor_sha: str | None,
descendant_sha: str | None,
) -> dict:
"""Observe whether ``descendant_sha`` strictly descends from ``ancestor_sha`` (#768).
Server-side git observation for dead-session lock recovery. The recovering
author's only reachable clean-worktree state is one commit *ahead* of the
head recorded at lock time, so recovery needs to know whether that commit
extends the recorded head or replaces it.
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
field is read from git in the declared worktree nothing here is supplied
by, or reachable from, an MCP caller (#768 AC6).
``ancestor_present`` proves the recorded head is still reachable, which is
what separates an honest fast-forward from a rewritten or force-moved
history: a rewritten recorded head leaves the object graph and the probe
fails closed.
"""
path = (worktree_path or "").strip()
ancestor = (ancestor_sha or "").strip()
descendant = (descendant_sha or "").strip()
result: dict = {
"ancestor_sha": ancestor or None,
"descendant_sha": descendant or None,
"probe_ok": False,
"ancestor_present": False,
"descendant_present": False,
"is_ancestor": False,
"is_strict_descendant": False,
"proof": None,
"reasons": [],
}
if not path or not ancestor or not descendant:
result["reasons"].append(
"ancestry probe requires a worktree path and both commit SHAs"
)
return result
def _present(sha: str) -> bool:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"],
capture_output=True,
text=True,
check=False,
)
return res.returncode == 0
try:
result["ancestor_present"] = _present(ancestor)
result["descendant_present"] = _present(descendant)
except OSError as exc: # git unavailable — fail closed, never assume
result["reasons"].append(f"ancestry probe could not run: {exc}")
return result
if not result["ancestor_present"]:
result["reasons"].append(
f"recorded head {ancestor} is not reachable in '{path}'; history may "
"have been rewritten or force-moved"
)
if not result["descendant_present"]:
result["reasons"].append(
f"local head {descendant} is not reachable in '{path}'"
)
if not (result["ancestor_present"] and result["descendant_present"]):
return result
probe = subprocess.run(
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
capture_output=True,
text=True,
check=False,
)
# 0 = is an ancestor, 1 = is not. Anything else is a failed probe, not a "no".
if probe.returncode not in (0, 1):
result["reasons"].append(
f"ancestry probe failed with exit {probe.returncode}; ancestry unproven"
)
return result
result["probe_ok"] = True
result["is_ancestor"] = probe.returncode == 0
result["is_strict_descendant"] = result["is_ancestor"] and ancestor != descendant
result["proof"] = (
f"git -C <worktree> merge-base --is-ancestor {ancestor} {descendant} "
f"-> exit {probe.returncode}"
)
if not result["is_ancestor"]:
result["reasons"].append(
f"local head {descendant} does not descend from recorded head {ancestor}"
)
elif not result["is_strict_descendant"]:
result["reasons"].append(
f"local head {descendant} equals the recorded head; no descendant "
"recovery is involved"
)
return result
def read_recorded_base(
worktree_path: str,
*,
head_sha: str | None,
extra_bases: tuple[str, ...] | list[str] = (),
base_branches: frozenset[str] | None = None,
) -> dict:
"""Observe the base commit an unpublished claim was branched from (#772).
A published claim records its base implicitly: the remote branch head is the
thing recovery measures against. An unpublished claim has no remote ref, so
the base must be observed here, server-side, as the merge-base between the
worktree HEAD and the base branch it was cut from.
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
field is read from git in the declared worktree nothing is supplied by, or
reachable from, an MCP caller, so a caller cannot nominate a base that would
make unrelated history look like a descendant (#772 AC1/AC4).
A HEAD with no common ancestor in any base branch yields ``probe_ok`` with no
``base_sha``: unrelated history is reported as exactly that, never as a base.
"""
path = (worktree_path or "").strip()
head = (head_sha or "").strip()
bases = base_branches or BASE_BRANCHES
candidates = [*extra_bases, *sorted(bases)]
result: dict = {
"base_branch": None,
"base_sha": None,
"head_sha": head or None,
"probe_ok": False,
"candidates": candidates,
"reasons": [],
}
if not path or not head:
result["reasons"].append(
"recorded-base probe requires a worktree path and a HEAD sha"
)
return result
probed_any = False
for candidate in candidates:
name = (candidate or "").strip()
if not name:
continue
probe = subprocess.run(
["git", "-C", path, "merge-base", name, head],
capture_output=True,
text=True,
check=False,
)
if probe.returncode not in (0, 1):
# 0 = merge base found, 1 = no common ancestor. Anything else is a
# failed probe (missing ref, broken repo) — try the next candidate.
continue
probed_any = True
merge_base = (probe.stdout or "").strip()
if probe.returncode == 0 and merge_base:
result["base_branch"] = name
result["base_sha"] = merge_base
result["probe_ok"] = True
return result
result["probe_ok"] = probed_any
if probed_any:
result["reasons"].append(
f"HEAD {head} shares no common ancestor with any of "
f"{_base_list(bases)}; history is unrelated to this repository's base"
)
else:
result["reasons"].append(
f"recorded-base probe could not run against any of {_base_list(bases)} "
f"in '{path}'"
)
return result
def read_worktree_git_state(
worktree_path: str,
extra_bases: tuple[str, ...] | list[str] = (),
@@ -92,8 +284,19 @@ def assess_issue_lock_worktree(
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_branches: frozenset[str] | None = None,
recovery_sanctioned: bool = False,
) -> dict:
"""Fail closed when lock preconditions are not met on the declared worktree."""
"""Fail closed when lock preconditions are not met on the declared worktree.
``recovery_sanctioned`` is set only when ``issue_lock_recovery`` has already
proven, from the durable lock itself, that this is a dead-session recovery of
an existing claim (#753): same issue, branch, worktree, author, and head, with
the recording process dead. In that one case the base-equivalence requirement
is waived, because a branch that already carries the work is ahead of its base
by construction and could never satisfy it. Every other precondition
notably worktree cleanliness still applies unchanged, and brand-new issue
claims keep the full base-equivalence requirement.
"""
bases = base_branches or BASE_BRANCHES
reasons: list[str] = []
path = (worktree_path or "").strip()
@@ -111,7 +314,11 @@ def assess_issue_lock_worktree(
f"(dirty files: {', '.join(dirty_files)})"
)
if base_equivalent is False:
if recovery_sanctioned:
# Base-equivalence intentionally not evaluated: ownership was proven
# against the durable lock record instead (#753).
pass
elif base_equivalent is False:
reasons.append(
"issue lock worktree must be base-equivalent to one of "
f"{_base_list(bases)} before implementation work; inspected "
@@ -139,6 +346,7 @@ def assess_issue_lock_worktree(
inspected_git_root=inspected_git_root,
base_branch=base_branch,
base_equivalent=base_equivalent,
recovery_sanctioned=recovery_sanctioned,
)
@@ -197,6 +405,7 @@ def _assessment(
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_equivalent: bool | None = None,
recovery_sanctioned: bool = False,
) -> dict:
return {
"proven": proven,
@@ -208,6 +417,8 @@ def _assessment(
"dirty_files": dirty_files,
"base_branch": base_branch,
"base_equivalent": base_equivalent,
"recovery_sanctioned": recovery_sanctioned,
"base_equivalence_waived": bool(recovery_sanctioned),
}
+149 -6
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any
from typing import Any, Mapping
import issue_claim_heartbeat as claim_hb
@@ -27,6 +27,128 @@ def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
return claim_hb._linked_open_pr(issue_number, open_prs)
def _pr_links_issue(issue_number: int, pr: Mapping[str, Any]) -> bool:
"""Same linkage rule ``claim_hb._linked_open_pr`` applies, per PR.
``_linked_open_pr`` only yields the *first* match, which cannot answer
"is there exactly one linked PR?" a question the owning-PR exemption
below must answer before it can trust any of them.
"""
pattern = _issue_pattern(issue_number)
head = (pr.get("head") or {}).get("ref") or ""
text = f"{pr.get('title', '')} {pr.get('body', '')}".lower()
if pattern in head.lower():
return True
return (
f"closes #{int(issue_number)}" in text
or f"fixes #{int(issue_number)}" in text
)
def _all_linked_open_prs(
issue_number: int, open_prs: list[dict]
) -> list[Mapping[str, Any]]:
return [pr for pr in (open_prs or []) if _pr_links_issue(issue_number, pr)]
def _assess_owning_pr_exemption(
issue_number: int,
*,
linked_open_prs: list[Mapping[str, Any]],
locked_branch: str | None,
recovered_owning_pr: Mapping[str, Any] | None,
) -> tuple[bool, list[str]]:
"""Is the linked open PR provably the one a sanctioned recovery owns (#755)?
``recovered_owning_pr`` is produced by
``issue_lock_recovery.owning_pr_recovery_evidence`` from a completed
server-side recovery assessment it is never a caller-supplied field.
Every element is re-checked here against the live PR list this gate was
given, so a stale or partial token cannot widen the exemption.
Returns ``(exempt, diagnostic_reasons)``. Diagnostics are only emitted when
a token was offered and rejected, so a blocked caller can see which element
of ownership disagreed.
"""
if not recovered_owning_pr:
return False, []
notes: list[str] = []
token_issue = recovered_owning_pr.get("issue_number")
token_pr = recovered_owning_pr.get("pr_number")
token_branch = str(recovered_owning_pr.get("branch_name") or "").strip()
token_head = str(recovered_owning_pr.get("head_sha") or "").strip()
locked = (locked_branch or "").strip()
if token_issue is not None and int(token_issue) != int(issue_number):
notes.append(
f"recovery evidence is for issue #{token_issue}, not "
f"#{issue_number} (no owning-PR exemption)"
)
return False, notes
if not locked or not token_branch or locked != token_branch:
notes.append(
f"recovery evidence branch '{token_branch or 'unknown'}' does not "
f"match the branch being locked '{locked or 'unknown'}' "
"(no owning-PR exemption)"
)
return False, notes
if len(linked_open_prs) != 1:
numbers = ", ".join(
f"#{pr.get('number')}" for pr in linked_open_prs
) or "none"
notes.append(
f"{len(linked_open_prs)} open PRs link issue #{issue_number} "
f"({numbers}); recovery may only own exactly one "
"(no owning-PR exemption)"
)
return False, notes
only = linked_open_prs[0]
head_obj = only.get("head") or {}
only_number = only.get("number")
only_ref = str(head_obj.get("ref") or "").strip()
only_sha = str(head_obj.get("sha") or "").strip()
if token_pr is None or only_number is None or int(only_number) != int(token_pr):
notes.append(
f"linked open PR #{only_number} is not the recovered owning PR "
f"#{token_pr} (no owning-PR exemption)"
)
return False, notes
if only_ref != token_branch:
notes.append(
f"open PR #{only_number} head branch '{only_ref}' does not match "
f"the recovered branch '{token_branch}' (no owning-PR exemption)"
)
return False, notes
# #768: a descendant recovery is measured against the head the PR still
# shows, then publishes the local descendant — so the live PR head is the
# recorded head before that push and the accepted head after it. Both are
# server-derived and name the same owned PR, so both are accepted; anything
# else still fails closed.
token_accepted = str(recovered_owning_pr.get("accepted_head") or "").strip()
acceptable_heads = [head for head in (token_head, token_accepted) if head]
if not acceptable_heads or not only_sha or only_sha not in acceptable_heads:
notes.append(
f"open PR #{only_number} head {only_sha or 'unknown'} does not "
f"match the recovered head {token_head or 'unknown'}"
+ (
f" or the accepted head {token_accepted}"
if token_accepted and token_accepted != token_head
else ""
)
+ " (no owning-PR exemption)"
)
return False, notes
return True, [
f"open PR #{only_number} is the exact PR already owned by the "
f"recovering lock for issue #{issue_number} (branch '{token_branch}', "
f"head {only_sha}); not duplicate work"
]
def _matching_branches(
issue_number: int,
branch_names: list[str],
@@ -52,8 +174,15 @@ def assess_work_issue_duplicate_gate(
claim_entry: dict | None = None,
locked_branch: str | None = None,
phase: str = PHASE_LOCK,
recovered_owning_pr: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Fail closed when duplicate work is already in flight for an issue."""
"""Fail closed when duplicate work is already in flight for an issue.
``recovered_owning_pr`` (#755) is server-derived evidence that a sanctioned
dead-session lock recovery already owns one specific open PR. It exempts
*only* that exact PR from the linked-open-PR blocker; every other duplicate
signal, and every mismatch, keeps failing closed.
"""
reasons: list[str] = []
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
prs = list(open_prs or [])
@@ -61,12 +190,23 @@ def assess_work_issue_duplicate_gate(
pattern = _issue_pattern(issue_number)
linked = _linked_open_pr(issue_number, prs)
linked_open_prs = _all_linked_open_prs(issue_number, prs)
owning_pr_exempted = False
exemption_notes: list[str] = []
if linked:
reasons.append(
f"open PR #{linked.get('number')} already covers issue "
f"#{issue_number} (fail closed)"
owning_pr_exempted, exemption_notes = _assess_owning_pr_exemption(
issue_number,
linked_open_prs=linked_open_prs,
locked_branch=locked_branch,
recovered_owning_pr=recovered_owning_pr,
)
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
if not owning_pr_exempted:
reasons.append(
f"open PR #{linked.get('number')} already covers issue "
f"#{issue_number} (fail closed)"
)
reasons.extend(exemption_notes)
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
conflicting_branches = _matching_branches(
issue_number, branches, locked_branch=locked_branch
@@ -122,6 +262,9 @@ def assess_work_issue_duplicate_gate(
"phase": phase,
"outcome": outcome,
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
"linked_open_pr_count": len(linked_open_prs),
"owning_pr_recovery_exempted": owning_pr_exempted,
"owning_pr_recovery_notes": list(exemption_notes),
"conflicting_branches": conflicting_branches,
"claim_status": status or None,
"reasons": reasons,
+171 -17
View File
@@ -24,12 +24,26 @@ from __future__ import annotations
import os
import subprocess
import time
# Live-remote head cache: the parity gate runs on every mutation and every
# runtime-context read, so the ``git ls-remote`` result is cached briefly to
# avoid a network round-trip per call (#610). Keyed by (root, remote, branch).
_REMOTE_HEAD_CACHE: dict[tuple[str, str, str], tuple[float, str | None]] = {}
_REMOTE_HEAD_TTL = 60.0
def _clear_remote_head_cache() -> None:
"""Reset the live-remote head cache (test isolation / forced refresh)."""
_REMOTE_HEAD_CACHE.clear()
# Environment escape hatches (ops + tests):
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
# GITEA_TEST_LIVE_REMOTE_HEAD -> force the live remote master read, for tests.
ENV_TEST_LIVE_REMOTE_HEAD = "GITEA_TEST_LIVE_REMOTE_HEAD"
def read_git_head(root: str) -> str | None:
@@ -58,6 +72,57 @@ def read_git_head(root: str) -> str | None:
return (res.stdout or "").strip() or None
def read_remote_master_head(
root: str,
remote: str = "origin",
branch: str = "master",
ttl: float = _REMOTE_HEAD_TTL,
) -> str | None:
"""Return the live remote ``branch`` commit SHA, or ``None`` (#610).
Resolves the *live* target commit via ``git ls-remote`` so parity can tell
a daemon that is behind the live remote master apart from one whose local
checkout simply hasn't been pulled. ``None`` means the live head could not
be resolved (offline, no such remote, git unavailable, error) -- callers
must treat unknown live state as *not mutation-safe* while never blocking
read-only diagnostics. A ``GITEA_TEST_LIVE_REMOTE_HEAD`` override takes
precedence so the wiring can be exercised deterministically and offline.
The result is cached for *ttl* seconds per (root, remote, branch) so the
gate does not run a network probe on every mutation/read (``ttl=0`` forces
a live probe). Both hits and ``None`` misses are cached to bound offline
latency; the env override bypasses the cache and the subprocess entirely.
"""
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
if forced is not None:
return forced.strip() or None
if not root:
return None
key = (root, remote, branch)
now = time.monotonic()
if ttl > 0:
cached = _REMOTE_HEAD_CACHE.get(key)
if cached is not None and (now - cached[0]) < ttl:
return cached[1]
sha: str | None = None
try:
res = subprocess.run(
["git", "-C", root, "ls-remote", remote, f"refs/heads/{branch}"],
capture_output=True,
text=True,
check=False,
timeout=5,
)
if res.returncode == 0:
lines = (res.stdout or "").strip().splitlines()
if lines:
sha = lines[0].split("\t", 1)[0].split()[0].strip() or None
except Exception:
sha = None
_REMOTE_HEAD_CACHE[key] = (now, sha)
return sha
def capture_startup_parity(root: str, head: str | None = None) -> dict:
"""Capture the process source-tree baseline once at server startup.
@@ -72,18 +137,38 @@ def _short(sha: str | None) -> str:
return sha[:12] if sha else "unknown"
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
def assess_master_parity(
startup: dict | None,
current_head: str | None,
live_remote_head: str | None = None,
) -> dict:
"""Compare the startup baseline against the current on-disk ``HEAD``.
Pure: both HEADs are supplied by the caller. Returns a structured result:
Pure: all HEADs are supplied by the caller. Returns a structured result:
- ``in_parity`` -- server code matches the on-disk master (or parity
could not be determined, which is not treated as stale).
- ``stale`` -- the on-disk master has definitively advanced past the
running process.
- ``restart_required`` -- alias of ``stale``; the recovery action.
- ``determinable`` -- whether both HEADs were known well enough to compare.
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
- ``determinable`` -- whether both local HEADs were known well enough to
compare.
- ``startup_head`` / ``current_head`` / ``reasons``.
#610 adds live-remote awareness so a daemon that is stale relative to the
*live* remote master cannot report a mutation-safe result even when the
local checkout HEAD still matches the daemon's startup commit:
- ``daemon_start_head`` -- the commit the running process started at
(alias of ``startup_head``, named for clarity in reports).
- ``local_head`` -- the on-disk checkout HEAD (alias of ``current_head``).
- ``live_remote_head`` -- the live remote target commit, or ``None`` when it
could not be fetched.
- ``live_known`` -- whether the live remote target was resolved.
- ``live_stale`` -- the live remote master has advanced past the running
process (daemon is behind live master) even if local parity is green.
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
target all agree; the only state in which a mutation may rely on parity.
"""
startup_head = (startup or {}).get("startup_head")
reasons: list[str] = []
@@ -91,32 +176,56 @@ def assess_master_parity(startup: dict | None, current_head: str | None) -> dict
if startup_head is None:
reasons.append(
"startup commit was not captured; code parity cannot be enforced")
return _result(True, False, False, startup_head, current_head, reasons)
return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons)
if current_head is None:
reasons.append(
"current workspace HEAD could not be read; code parity cannot be "
"enforced")
return _result(True, False, False, startup_head, current_head, reasons)
return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons)
if startup_head == current_head:
return _result(True, False, True, startup_head, current_head, reasons)
local_in_parity = startup_head == current_head
local_stale = not local_in_parity
if local_stale:
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the "
f"workspace master is now {_short(current_head)}; restart the "
f"server to load the current capability gates")
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the workspace "
f"master is now {_short(current_head)}; restart the server to load the "
f"current capability gates")
return _result(False, True, True, startup_head, current_head, reasons)
live_known = live_remote_head is not None
live_stale = live_known and live_remote_head != startup_head
if live_stale:
reasons.append(
f"live remote master is {_short(live_remote_head)} but the MCP "
f"server started at {_short(startup_head)}; the daemon is stale "
f"relative to live master -- restart/reconnect before mutating")
return _result(
local_in_parity, local_stale, True, startup_head, current_head,
live_remote_head, live_stale, reasons)
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
def _result(in_parity, stale, determinable, startup_head, current_head,
live_remote_head, live_stale, reasons):
live_known = live_remote_head is not None
mutation_safe = (
determinable and in_parity and live_known and not live_stale)
return {
"in_parity": in_parity,
"stale": stale,
"restart_required": stale,
"restart_required": stale or live_stale,
"determinable": determinable,
"startup_head": startup_head,
"current_head": current_head,
# #610 distinguished signals:
"daemon_start_head": startup_head,
"local_head": current_head,
"live_remote_head": live_remote_head,
"live_known": live_known,
"live_stale": live_stale,
"mutation_safe": mutation_safe,
"reasons": list(reasons),
}
@@ -130,11 +239,13 @@ def parity_block_reasons(assessment: dict) -> list[str]:
"""Block reasons for a mutation gate (empty when the mutation may proceed).
A disabled gate or an in-parity / non-determinable assessment yields no
reasons; only a definitively stale server blocks.
reasons. A definitively stale server blocks, and (#610) a daemon that is
stale relative to the *live* remote master blocks even when the local
checkout HEAD still matches the daemon's startup commit.
"""
if gate_disabled():
return []
if assessment.get("stale"):
if assessment.get("stale") or assessment.get("live_stale"):
return list(assessment.get("reasons") or
["server code is stale relative to master (fail closed)"])
return []
@@ -147,6 +258,10 @@ def parity_report(assessment: dict) -> dict:
"restart_required": True,
"startup_head": assessment.get("startup_head"),
"current_head": assessment.get("current_head"),
# #610: name the live remote target so the report distinguishes a
# local-code stale from a daemon-behind-live-master stale.
"live_remote_head": assessment.get("live_remote_head"),
"live_stale": bool(assessment.get("live_stale")),
"reasons": list(assessment.get("reasons") or []),
"recovery": [
"The running MCP server is executing code older than the current "
@@ -157,6 +272,45 @@ def parity_report(assessment: dict) -> dict:
}
def parity_resolver_disagreement(
assessment: dict,
resolver_restart_required: bool,
) -> dict | None:
"""Typed blocker when the resolver requires restart but parity looks green.
The capability resolver (``gitea_resolve_task_capability``) detects stale
runtime authoritatively for mutation safety (#610). When it requires a
restart, local-only parity must never override it: this returns a typed,
fail-closed blocker that names the resolver as authoritative. Returns
``None`` when the resolver does not require a restart.
"""
if not resolver_restart_required:
return None
parity_optimistic = bool(assessment.get("in_parity")) and not (
assessment.get("stale") or assessment.get("live_stale"))
return {
"kind": "parity_resolver_disagreement",
"restart_required": True,
"resolver_authoritative": True,
"parity_optimistic": parity_optimistic,
"daemon_start_head": assessment.get("daemon_start_head"),
"local_head": assessment.get("local_head"),
"live_remote_head": assessment.get("live_remote_head"),
"reasons": [
"The capability resolver requires a restart/reconnect (stale "
"runtime) but master-parity reported local code as in-parity. "
"The resolver is authoritative for mutation safety; do not mutate "
"on local parity alone. Restart/reconnect the Gitea MCP server "
"and re-verify before mutating.",
],
"recovery": [
"Trust the resolver: treat this session as stale.",
"Restart or /mcp reconnect the Gitea MCP namespace so it reloads "
"current master and live target state, then re-run preflight.",
],
}
def format_parity(assessment: dict) -> str:
"""One-line human summary for logs / runtime context."""
if assessment.get("stale"):
+43 -15
View File
@@ -19,6 +19,32 @@ print_banner() {
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
}
show_workflow_dashboard_help() {
printf '\n--- Workflow dashboard (queue, leases, next safe action) ---\n\n'
printf 'Read-only operational view (#605). Does NOT assign work.\n'
printf 'Exclusive assignment still requires gitea_allocate_next_work.\n\n'
printf 'Canonical MCP tool (any healthy Gitea namespace with gitea.read):\n\n'
printf ' gitea_workflow_dashboard(\n'
printf ' remote=\"prgs\",\n'
printf ' org=\"Scaled-Tech-Consulting\",\n'
printf ' repo=\"Gitea-Tools\",\n'
printf ' )\n\n'
printf 'Returns machine-readable sections:\n'
printf ' - open_pr_queue / open_issue_queue\n'
printf ' - active_leases_by_role / stale_or_expired_leases\n'
printf ' - terminal_review_lock\n'
printf ' - blocked_items (never presented as safe)\n'
printf ' - review_ready_prs / merge_ready_prs / author_remediation\n'
printf ' - discussion_issues / controller_needed\n'
printf ' - next_safe_by_role + primary_next_safe_action with exact prompts\n'
printf ' - human_summary (copy-friendly multi-line text)\n\n'
printf 'Safety:\n'
printf ' - Never suggests blocked or terminal-locked items as safe.\n'
printf ' - Incomplete inventory fails closed (no safe suggestions).\n'
printf ' - This menu entry is documentation only; it does not call Gitea.\n'
pause
}
show_root_checkout_health() {
printf '\n--- Project status / root checkout health ---\n\n'
printf 'Current directory: %s\n' "$(pwd)"
@@ -241,25 +267,27 @@ main_menu() {
while true; do
print_banner
printf ' 1) Project status / root checkout health\n'
printf ' 2) Author workflow prompts\n'
printf ' 3) Reviewer workflow prompts\n'
printf ' 4) Merger workflow prompts\n'
printf ' 5) Reconciler workflow prompts\n'
printf ' 6) Onboarding new project to this MCP workflow\n'
printf ' 7) Proxmox deployment menu placeholder\n'
printf ' 8) Create Proxmox LXC placeholder\n'
printf ' 9) Run tests\n'
printf ' 2) Workflow dashboard (queue, leases, next safe action)\n'
printf ' 3) Author workflow prompts\n'
printf ' 4) Reviewer workflow prompts\n'
printf ' 5) Merger workflow prompts\n'
printf ' 6) Reconciler workflow prompts\n'
printf ' 7) Onboarding new project to this MCP workflow\n'
printf ' 8) Proxmox deployment menu placeholder\n'
printf ' 9) Create Proxmox LXC placeholder\n'
printf ' t) Run tests\n'
printf ' 0) Exit\n'
read -r -p 'Choice: ' choice
case "$choice" in
1) show_root_checkout_health ;;
2) show_author_prompts ;;
3) show_reviewer_prompts ;;
4) show_merger_prompts ;;
5) show_reconciler_prompts ;;
6) show_onboarding_prompt ;;
7|8) show_proxmox_placeholder ;;
9) run_tests ;;
2) show_workflow_dashboard_help ;;
3) show_author_prompts ;;
4) show_reviewer_prompts ;;
5) show_merger_prompts ;;
6) show_reconciler_prompts ;;
7) show_onboarding_prompt ;;
8|9) show_proxmox_placeholder ;;
t|T|tests) run_tests ;;
0) printf 'Goodbye.\n'; exit 0 ;;
*) printf 'Invalid choice.\n'; pause ;;
esac
+9
View File
@@ -36,6 +36,11 @@ KIND_REVIEW_DRAFT = "review_draft"
# other session proofs; a contaminated session fails closed on gated mutations
# until a reconciler audits and clears it.
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
# Durable marker set when a worker session manually kills MCP daemon processes
# instead of using a sanctioned reconnect/restart path (#630). Same shape and
# same reconciler-only clear as the #671 marker above; kept as its own kind so
# an audit can tell the two contamination classes apart.
KIND_RUNTIME_RECOVERY_CONTAMINATION = "runtime_recovery_contamination"
# Durable shadow of the in-memory reviewer session lease (#702). Written on
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
# daemon that dies without teardown leaves provable orphan evidence (owner
@@ -71,6 +76,10 @@ RECOVERY_CRITICAL_KINDS = frozenset(
# #702 crash-orphan evidence (must outlive TTL; F4)
KIND_REVIEWER_SESSION_LEASE,
KIND_STALE_BINDING_RECOVERY,
# #630: contamination must not expire into cleanliness. A TTL-bound
# marker would let a contaminated session self-clear by waiting, which
# defeats the reconciler-only clear the gate depends on.
KIND_RUNTIME_RECOVERY_CONTAMINATION,
}
)
+195
View File
@@ -0,0 +1,195 @@
"""Documented-vs-registered MCP tool inventory guard (#781).
The workflow documentation named a ``gitea_edit_issue`` tool that no namespace
had ever registered. Nothing compared the two lists, so an actor could plan a
mutation against a tool that did not exist and only discover it at execution
time after the work was already scoped around it.
This module is that comparison, in two directions:
- :func:`assess_inventory_drift` compares the canonical inventory documented in
``docs/mcp-tool-inventory.md`` against the tools actually registered on the
MCP server. Either list drifting fails the guard, so a new tool must be
documented and a removed tool must be undocumented in the same change.
- :func:`assess_doc_references` catches the original defect directly: any tool
name a workflow/skill document tells an actor to call must be registered.
Module and script names legitimately appear in the same prose (``gitea_auth``,
``offline_mcp_runner``), so :data:`NON_TOOL_IDENTIFIERS` names the known
non-tool identifiers explicitly rather than loosening the pattern.
This module performs no I/O callers own reading the files.
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Mapping
#: Canonical documented inventory, relative to the repository root.
INVENTORY_DOC_PATH = "docs/mcp-tool-inventory.md"
#: Delimiters around the generated inventory list in the doc.
INVENTORY_BEGIN_MARKER = "<!-- BEGIN REGISTERED TOOL INVENTORY -->"
INVENTORY_END_MARKER = "<!-- END REGISTERED TOOL INVENTORY -->"
#: Backticked identifiers that look like tool names but are modules/scripts.
#: Every entry is a real file in this repository, not an MCP tool.
NON_TOOL_IDENTIFIERS: frozenset[str] = frozenset(
{
"gitea_auth",
"gitea_config",
"gitea_mcp_server",
"mcp_server",
"offline_mcp_helper",
"offline_mcp_runner",
}
)
#: Prefixes that mark an identifier as a candidate MCP tool name.
TOOL_NAME_PREFIXES: tuple[str, ...] = ("gitea_", "mcp_")
_INVENTORY_ENTRY = re.compile(r"^-\s+`([A-Za-z_][A-Za-z0-9_]*)`")
_BACKTICKED = re.compile(r"`([A-Za-z_][A-Za-z0-9_]*)`")
def parse_documented_inventory(text: str) -> list[str]:
"""Return the tool names listed between the inventory markers.
Raises ``ValueError`` when the markers are missing or out of order, so a
mangled document fails the guard instead of silently documenting nothing.
"""
start = text.find(INVENTORY_BEGIN_MARKER)
end = text.find(INVENTORY_END_MARKER)
if start == -1 or end == -1 or end < start:
raise ValueError(
f"{INVENTORY_DOC_PATH} must contain "
f"'{INVENTORY_BEGIN_MARKER}' followed by "
f"'{INVENTORY_END_MARKER}' (fail closed)."
)
block = text[start + len(INVENTORY_BEGIN_MARKER) : end]
names: list[str] = []
for line in block.splitlines():
match = _INVENTORY_ENTRY.match(line.strip())
if match:
names.append(match.group(1))
return names
def looks_like_tool_name(identifier: str) -> bool:
"""Return whether a backticked identifier is a candidate tool name."""
if identifier in NON_TOOL_IDENTIFIERS:
return False
return identifier.startswith(TOOL_NAME_PREFIXES)
def extract_tool_references(text: str) -> set[str]:
"""Return candidate tool names a document tells an actor to call."""
return {
name
for name in _BACKTICKED.findall(text)
if looks_like_tool_name(name)
}
def assess_inventory_drift(
documented: Iterable[str],
registered: Iterable[str],
) -> dict[str, Any]:
"""Compare the documented inventory against the registered tool set."""
documented_list = list(documented)
documented_set = set(documented_list)
registered_set = set(registered)
duplicates = sorted(
{name for name in documented_list if documented_list.count(name) > 1}
)
documented_not_registered = sorted(documented_set - registered_set)
registered_not_documented = sorted(registered_set - documented_set)
unsorted = documented_list != sorted(documented_list)
reasons: list[str] = []
if documented_not_registered:
reasons.append(
"documented but not registered: "
+ ", ".join(documented_not_registered)
)
if registered_not_documented:
reasons.append(
"registered but not documented: "
+ ", ".join(registered_not_documented)
)
if duplicates:
reasons.append("listed more than once: " + ", ".join(duplicates))
if unsorted:
reasons.append("inventory entries are not in sorted order")
in_sync = not reasons
return {
"in_sync": in_sync,
"documented_count": len(documented_set),
"registered_count": len(registered_set),
"documented_not_registered": documented_not_registered,
"registered_not_documented": registered_not_documented,
"duplicates": duplicates,
"sorted": not unsorted,
"reasons": reasons,
"safe_next_action": (
""
if in_sync
else (
f"Update {INVENTORY_DOC_PATH} so the block between the "
"inventory markers lists exactly the registered tools, sorted, "
"one '- `tool_name`' entry per line."
)
),
}
def assess_doc_references(
references: Mapping[str, Iterable[str]],
registered: Iterable[str],
) -> dict[str, Any]:
"""Verify every tool a document names is actually registered.
*references* maps a document path to the candidate tool names it mentions.
"""
registered_set = set(registered)
unregistered: list[dict[str, Any]] = []
checked = 0
for path, names in sorted(references.items()):
for name in sorted(set(names)):
checked += 1
if name not in registered_set:
unregistered.append({"document": path, "tool": name})
clean = not unregistered
reasons = [
f"{entry['document']} documents '{entry['tool']}', "
"which no namespace registers"
for entry in unregistered
]
return {
"clean": clean,
"checked_count": checked,
"unregistered": unregistered,
"reasons": reasons,
"safe_next_action": (
""
if clean
else (
"Either register the named tool with @mcp.tool() or correct the "
"document. Documentation must never name a tool an actor cannot "
"reach. If the identifier is a module or script rather than a "
"tool, add it to mcp_tool_inventory.NON_TOOL_IDENTIFIERS."
)
),
}
def render_inventory_block(registered: Iterable[str]) -> str:
"""Render the marker-delimited inventory block for the documentation."""
lines = [INVENTORY_BEGIN_MARKER, ""]
lines.extend(f"- `{name}`" for name in sorted(set(registered)))
lines.extend(["", INVENTORY_END_MARKER])
return "\n".join(lines)
+286
View File
@@ -0,0 +1,286 @@
"""Mutation-budget classification for auto-mode attempts (#617).
Mutation budget must count only *server-side* Gitea state changes. A tool call
that fails closed before the Gitea API is reached changed nothing on the
server, so it must not consume the budget that protects against repeated real
mutations.
The classifier separates four outcome classes plus an explicit ambiguous class:
``local_validator_rejection``
A canonical-content validator (for example the ``[THREAD STATE LEDGER]`` or
``## Canonical Issue State`` blocks) rejected the payload before any API
call. No server-side state exists.
``capability_gate_rejection``
A profile/permission gate refused the operation before any API call.
``transport_failure_before_api``
The request never reached the Gitea API (transport/EOF/connection error).
``server_side_mutation``
The API succeeded and returned proof of durable state (comment id, review
id, merge commit, label result, or an issue/PR state change).
``ambiguous_requires_readback``
The API *was* reached but the result carries no usable proof either way.
This fails closed: the attempt is treated as budget-consuming until a
read-after-write check proves otherwise, so #617 never weakens the guard
that prevents repeated real mutations.
Only ``server_side_mutation`` consumes budget outright. Every attempt failed
or not is still recorded in the local attempt ledger so a final report can
show local failed attempts, blocked API attempts, and successful server-side
mutations separately.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
LOCAL_VALIDATOR_REJECTION = "local_validator_rejection"
CAPABILITY_GATE_REJECTION = "capability_gate_rejection"
TRANSPORT_FAILURE_BEFORE_API = "transport_failure_before_api"
SERVER_SIDE_MUTATION = "server_side_mutation"
AMBIGUOUS_REQUIRES_READBACK = "ambiguous_requires_readback"
CLASSIFICATIONS = (
LOCAL_VALIDATOR_REJECTION,
CAPABILITY_GATE_REJECTION,
TRANSPORT_FAILURE_BEFORE_API,
SERVER_SIDE_MUTATION,
AMBIGUOUS_REQUIRES_READBACK,
)
#: Result fields that prove durable server-side state was created.
MUTATION_PROOF_FIELDS = (
"comment_id",
"review_id",
"merge_commit_sha",
"label_result",
"state_change",
"created_pr_number",
)
#: Classes that never consume server-side mutation budget.
PRE_API_CLASSIFICATIONS = (
LOCAL_VALIDATOR_REJECTION,
CAPABILITY_GATE_REJECTION,
TRANSPORT_FAILURE_BEFORE_API,
)
FINAL_REPORT_REQUIRED_FIELDS = (
"local_failed_attempts",
"blocked_api_attempts",
"successful_server_mutations",
)
def _clean(value: Any) -> str:
return (value or "").strip() if isinstance(value, str) else str(value or "").strip()
def _proof_fields_present(result: dict) -> list[str]:
"""Return the mutation-proof fields carrying a usable value."""
present: list[str] = []
for field in MUTATION_PROOF_FIELDS:
value = result.get(field)
if value is None or value is False:
continue
if isinstance(value, str) and not value.strip():
continue
present.append(field)
return present
def _decision(
classification: str,
*,
budget_consumed: bool,
requires_readback: bool,
reasons: list[str],
proof_fields: list[str],
api_called: bool | None,
) -> dict:
return {
"classification": classification,
"budget_consumed": budget_consumed,
"requires_readback": requires_readback,
"pre_api": classification in PRE_API_CLASSIFICATIONS,
"api_called": api_called,
"proof_fields": proof_fields,
"reasons": reasons,
}
def classify_mutation_attempt(result: dict | None) -> dict:
"""Classify one mutation attempt and decide whether it consumes budget.
``result`` is the raw dict a Gitea MCP tool returned. The caller does not
pre-interpret it: classification is driven by the explicit ``api_called``
signal plus the proof fields the tool reports.
"""
data = dict(result or {})
success = bool(data.get("success"))
proof_fields = _proof_fields_present(data)
api_called = data.get("api_called")
# An unambiguous success carrying durable proof is a real mutation however
# the attempt was labelled upstream.
if success and proof_fields:
return _decision(
SERVER_SIDE_MUTATION,
budget_consumed=True,
requires_readback=False,
reasons=[
"API reported success with durable proof field(s): "
+ ", ".join(proof_fields)
],
proof_fields=proof_fields,
api_called=True,
)
if api_called is False:
# Nothing reached the server; pick the precise pre-API class.
if data.get("transport_error") or data.get("transport_failed"):
return _decision(
TRANSPORT_FAILURE_BEFORE_API,
budget_consumed=False,
requires_readback=False,
reasons=["transport failed before the Gitea API was reached"],
proof_fields=[],
api_called=False,
)
if data.get("permission_report") or data.get("capability_blocked"):
return _decision(
CAPABILITY_GATE_REJECTION,
budget_consumed=False,
requires_readback=False,
reasons=["capability/permission gate refused before any API call"],
proof_fields=[],
api_called=False,
)
return _decision(
LOCAL_VALIDATOR_REJECTION,
budget_consumed=False,
requires_readback=False,
reasons=[
"local validator rejected the payload before any API call; "
"no server-side state was created"
],
proof_fields=[],
api_called=False,
)
if api_called is True:
if success:
reason = (
"API reported success but returned no durable proof field; "
"read-after-write verification required before counting budget"
)
else:
reason = (
"API was reached and the outcome carries no durable proof; "
"read-after-write verification required before counting budget"
)
return _decision(
AMBIGUOUS_REQUIRES_READBACK,
budget_consumed=True,
requires_readback=True,
reasons=[reason],
proof_fields=proof_fields,
api_called=True,
)
# ``api_called`` was not reported at all. Fail closed rather than assuming
# nothing happened.
return _decision(
AMBIGUOUS_REQUIRES_READBACK,
budget_consumed=True,
requires_readback=True,
reasons=[
"attempt did not report 'api_called'; cannot prove the request "
"stopped before the Gitea API, so the attempt fails closed"
],
proof_fields=proof_fields,
api_called=None,
)
def record_attempt(
ledger: list[dict] | None,
result: dict | None,
*,
operation: str = "",
timestamp: str | None = None,
) -> dict:
"""Append one classified attempt to the local ledger and return the entry.
Every attempt is recorded, including the ones that consume no budget: the
point of #617 is that failed local attempts stay visible without being
miscounted as Gitea mutations.
"""
entries = ledger if isinstance(ledger, list) else []
entry = {
"operation": _clean(operation),
"timestamp": _clean(timestamp) or datetime.now(timezone.utc).isoformat(),
**classify_mutation_attempt(result),
}
entries.append(entry)
return entry
def summarize_attempt_ledger(ledger: list[dict] | None) -> dict:
"""Summarize a ledger into the categories a final report must show."""
entries = [e for e in (ledger or []) if isinstance(e, dict)]
def _count(*classifications: str) -> int:
return sum(1 for e in entries if e.get("classification") in classifications)
return {
"total_attempts": len(entries),
"local_failed_attempts": _count(LOCAL_VALIDATOR_REJECTION),
"blocked_api_attempts": _count(
CAPABILITY_GATE_REJECTION, TRANSPORT_FAILURE_BEFORE_API
),
"successful_server_mutations": _count(SERVER_SIDE_MUTATION),
"ambiguous_attempts": _count(AMBIGUOUS_REQUIRES_READBACK),
"budget_consumed": sum(1 for e in entries if e.get("budget_consumed")),
"requires_readback": any(e.get("requires_readback") for e in entries),
"entries": entries,
}
def assess_final_report_mutation_accounting(
report: dict | None,
ledger: list[dict] | None,
) -> dict:
"""Fail closed when a report's mutation accounting contradicts the ledger."""
data = dict(report or {})
summary = summarize_attempt_ledger(ledger)
reasons: list[str] = []
for field in FINAL_REPORT_REQUIRED_FIELDS:
if field not in data:
reasons.append(f"final report omits required field '{field}'")
continue
claimed = data.get(field)
actual = summary[field]
if claimed != actual:
reasons.append(
f"final report claims {field}={claimed} but the attempt ledger "
f"shows {actual}"
)
if summary["requires_readback"] and not data.get("readback_verified"):
reasons.append(
"ledger contains an ambiguous attempt; final report must record "
"'readback_verified' proof before claiming mutation accounting"
)
return {
"valid": not reasons,
"reasons": reasons,
"ledger_summary": {k: v for k, v in summary.items() if k != "entries"},
}
+157 -39
View File
@@ -55,18 +55,26 @@ def resolve_namespace_workspace(
process_project_root: str,
env: dict[str, str] | os._Environ | None = None,
session_lease_worktree: str | None = None,
session_lock_worktree: str | None = None,
profile_name: str | None = None,
demotions: list[str] | None = None,
verify_paths: bool = False,
durable_author_result: dict | None = None,
) -> tuple[str, str]:
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
With *verify_paths*, env-sourced candidates whose path no longer exists
are demoted (#702): a binding to a deleted worktree can never name a
valid task workspace, so resolution falls through to the next candidate.
Explicit arguments are never demoted a caller-declared path must fail
loudly downstream rather than silently rebind. Demotion notes are
appended to *demotions* when provided. Runtime-context and mutation
are demoted (#702) for non-author roles: a binding to a deleted worktree
can never name a valid task workspace, so resolution falls through to the
next candidate. Explicit arguments are never demoted a caller-declared
path must fail loudly downstream rather than silently rebind. Demotion
notes are appended to *demotions* when provided.
Author role (#618): never demotes a missing configured binding to the
control checkout. When *verify_paths* is true, resolution goes through
:func:`author_mutation_worktree.resolve_durable_author_worktree` so
mutations either use an explicit validated worktree, derive from the
active author issue lock, or fail closed. Runtime-context and mutation
guards resolve through :func:`resolve_namespace_mutation_context`, which
always verifies.
"""
@@ -74,6 +82,32 @@ def resolve_namespace_workspace(
role = normalize_role_kind(role_kind, profile_name=profile_name)
role_env_key = ROLE_WORKTREE_ENVS[role]
# #618: durable author resolution — no silent control/master fallback.
if role == "author" and verify_paths:
durable = durable_author_result
if durable is None:
durable = amw.resolve_durable_author_worktree(
worktree_path=worktree_path,
worktree=worktree,
process_project_root=process_project_root,
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
session_lock_worktree=session_lock_worktree,
profile_name=profile_name,
# Path selection only here; full validation is re-run in
# resolve_namespace_mutation_context with the canonical root.
validate=False,
)
workspace = durable.get("workspace_path") or os.path.realpath(
process_project_root
)
source = durable.get("workspace_binding_source") or "no author worktree binding"
if demotions is not None and durable.get("bound_worktree_missing"):
demotions.append(
f"{source} '{workspace}' not demoted: {amw.BOUND_WORKTREE_MISSING_MESSAGE}"
)
return workspace, source
for candidate, source, env_sourced in (
(worktree_path, "worktree_path argument", False),
(worktree, "worktree argument", False),
@@ -83,6 +117,11 @@ def resolve_namespace_workspace(
f"{role_env_key} environment variable", True),
(session_lease_worktree if role in {"reviewer", "merger"} else None,
"reviewer PR lease worktree", False),
# Author lock derivation is handled by the durable path above when
# verify_paths is true; when verify_paths is false, surface the lock
# path as a non-demoted candidate so tooling can inspect it.
(session_lock_worktree if role == "author" else None,
"active author issue lock worktree", False),
):
text = (candidate or "").strip()
if not text:
@@ -107,6 +146,7 @@ def resolve_namespace_mutation_context(
process_project_root: str,
env: dict[str, str] | os._Environ | None = None,
session_lease_worktree: str | None = None,
session_lock_worktree: str | None = None,
worktree: str | None = None,
profile_name: str | None = None,
configured_canonical_root: str | None = None,
@@ -119,21 +159,54 @@ def resolve_namespace_mutation_context(
the branches-only / worktree-membership guards (#274) evaluating against the
repository the namespace actually mutates. Without it the single-repo
default is preserved: the canonical root follows the process checkout.
Author role (#618): uses durable worktree resolution (explicit path, env,
or active issue lock) and never silently falls back to the control checkout.
"""
demotions: list[str] = []
workspace, binding_source = resolve_namespace_workspace(
role_kind=role_kind,
worktree_path=worktree_path,
worktree=worktree,
process_project_root=process_project_root,
env=env,
session_lease_worktree=session_lease_worktree,
profile_name=profile_name,
demotions=demotions,
verify_paths=True,
)
env_map = env if env is not None else os.environ
process_root = os.path.realpath(process_project_root)
role = normalize_role_kind(role_kind, profile_name=profile_name)
configured = (configured_canonical_root or "").strip()
if configured:
canonical_root = os.path.realpath(configured)
else:
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
durable: dict | None = None
if role == "author":
durable = amw.resolve_durable_author_worktree(
worktree_path=worktree_path,
worktree=worktree,
process_project_root=process_root,
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
session_lock_worktree=session_lock_worktree,
canonical_repo_root=canonical_root,
profile_name=profile_name,
validate=True,
)
workspace = durable["workspace_path"]
binding_source = durable["workspace_binding_source"]
if durable.get("bound_worktree_missing"):
demotions.append(
f"{binding_source} '{workspace}' not demoted: "
f"{amw.BOUND_WORKTREE_MISSING_MESSAGE}"
)
else:
workspace, binding_source = resolve_namespace_workspace(
role_kind=role,
worktree_path=worktree_path,
worktree=worktree,
process_project_root=process_project_root,
env=env,
session_lease_worktree=session_lease_worktree,
session_lock_worktree=session_lock_worktree,
profile_name=profile_name,
demotions=demotions,
verify_paths=True,
)
pollution = assess_foreign_role_worktree_pollution(
role_kind=role,
resolved_workspace=workspace,
@@ -141,12 +214,7 @@ def resolve_namespace_mutation_context(
env=env,
profile_name=profile_name,
)
configured = (configured_canonical_root or "").strip()
if configured:
canonical_root = os.path.realpath(configured)
else:
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
return {
result = {
"workspace_path": workspace,
"workspace_binding_source": binding_source,
"workspace_role_kind": role,
@@ -155,6 +223,17 @@ def resolve_namespace_mutation_context(
"canonical_repo_root": canonical_root,
"roots_aligned": canonical_root == process_root,
}
if durable is not None:
result["author_worktree_resolution"] = durable
result["bound_worktree_missing"] = bool(durable.get("bound_worktree_missing"))
result["path_exists"] = durable.get("path_exists")
result["in_git_worktree_list"] = durable.get("in_git_worktree_list")
result["inspected_git_root"] = durable.get("inspected_git_root")
result["author_worktree_block"] = bool(durable.get("block"))
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
result["operator_recovery"] = durable.get("operator_recovery")
return result
def assess_foreign_role_worktree_pollution(
@@ -231,10 +310,29 @@ def format_namespace_workspace_binding_error(
reasons: list[str] | None = None,
ignored_bindings: list[str] | None = None,
dirty_files: list[str] | None = None,
operator_recovery: str | None = None,
) -> str:
"""Canonical error when namespace workspace binding blocks mutations."""
role = normalize_role_kind(role_kind)
workspace = os.path.realpath(workspace_path)
reason_list = list(reasons or [])
# #618: prefer the durable author missing-worktree message when present.
if role == "author" and any(
amw.BOUND_WORKTREE_MISSING_MESSAGE in r for r in reason_list
):
return amw.format_bound_worktree_missing_error(
{
"reasons": reason_list,
"binding_source": binding_source,
"configured_path": workspace_path,
"role_kind": role,
"operator_recovery": operator_recovery
or amw.OPERATOR_RECOVERY_RECREATE_REPOINT,
}
)
try:
workspace = os.path.realpath(workspace_path)
except OSError:
workspace = workspace_path
parts = [
f"Namespace workspace binding blocked ({role} namespace, #510): "
f"resolved workspace '{workspace}' via {binding_source}."
@@ -249,15 +347,18 @@ def format_namespace_workspace_binding_error(
+ ", ".join(dirty_files)
+ "."
)
if reasons:
parts.append("Details: " + "; ".join(reasons) + ".")
parts.append(
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
f"branches/ {role} worktree, set "
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
"to that path, or pass worktree_path on mutation tools. Do not clean or "
"reset foreign role worktrees to unblock this namespace."
)
if reason_list:
parts.append("Details: " + "; ".join(reason_list) + ".")
if operator_recovery:
parts.append(f"Operator recovery: {operator_recovery}")
else:
parts.append(
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
f"branches/ {role} worktree, set "
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
"to that path, or pass worktree_path on mutation tools. Do not clean or "
"reset foreign role worktrees to unblock this namespace."
)
return " ".join(parts)
@@ -269,6 +370,7 @@ def assess_namespace_mutation_workspace(
process_project_root: str,
env: dict[str, str] | os._Environ | None = None,
session_lease_worktree: str | None = None,
session_lock_worktree: str | None = None,
profile_name: str | None = None,
current_branch: str | None = None,
configured_canonical_root: str | None = None,
@@ -281,6 +383,7 @@ def assess_namespace_mutation_workspace(
process_project_root=process_project_root,
env=env,
session_lease_worktree=session_lease_worktree,
session_lock_worktree=session_lock_worktree,
profile_name=profile_name,
configured_canonical_root=configured_canonical_root,
)
@@ -305,14 +408,23 @@ def assess_namespace_mutation_workspace(
)
reasons = list(metadata.get("reasons") or [])
operator_recovery = ctx.get("operator_recovery")
if role == "author":
branches = amw.assess_author_mutation_worktree(
workspace_path=mutation_workspace,
project_root=ctx["canonical_repo_root"],
current_branch=current_branch,
)
if branches["block"]:
reasons.extend(branches["reasons"])
# #618 durable resolution already validated existence, membership,
# branches/, lock ownership, and traversal safety when present.
durable_reasons = list(ctx.get("author_worktree_reasons") or [])
if durable_reasons:
reasons.extend(durable_reasons)
elif ctx.get("author_worktree_block"):
reasons.append(amw.BOUND_WORKTREE_MISSING_MESSAGE)
else:
branches = amw.assess_author_mutation_worktree(
workspace_path=mutation_workspace,
project_root=ctx["canonical_repo_root"],
current_branch=current_branch,
)
if branches["block"]:
reasons.extend(branches["reasons"])
elif (
role == "reviewer"
and mutation_workspace == process_root
@@ -345,4 +457,10 @@ def assess_namespace_mutation_workspace(
"metadata_only": metadata.get("metadata_only", False),
"declared_worktree_path": metadata.get("declared_worktree_path"),
"ignored_bindings": pollution.get("ignored_bindings") or [],
"bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
"path_exists": ctx.get("path_exists"),
"in_git_worktree_list": ctx.get("in_git_worktree_list"),
"inspected_git_root": ctx.get("inspected_git_root"),
"operator_recovery": operator_recovery,
"blocker_kind": ctx.get("author_worktree_blocker_kind"),
}
+279
View File
@@ -0,0 +1,279 @@
"""Reconciler authorization gate for post-merge moot-lease cleanup (#745).
``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase:
released`` lease marker a real, durable mutation of the PR lease ledger.
Before #745 it was gated on permissions alone (``gitea.read`` to enter,
``gitea.pr.comment`` to apply) with no canonical task and no role binding, so
any profile carrying ``gitea.pr.comment`` reached the mutation path while the
reconciler could not satisfy the operator-required resolve-exact-task ->
mutation sequence.
This module holds the pure half of that gate:
* the canonical task name and its tool-name alias;
* an **append-only** in-process ledger of read-only dry-run assessments;
* ``assess_apply_authorization``, which decides whether an apply may proceed.
Apply is authorized only when all of the following hold:
* the session resolved exactly the cleanup task (no other task substitutes);
* the active profile role is ``reconciler``;
* a prior dry run in this session recorded ``lease_moot`` and
``cleanup_allowed`` for the *same* repository, PR, lease session, candidate
head and lease marker id;
* the live assessment still agrees with that evidence, so a lease superseded
between the dry run and the apply fails closed;
* any caller-supplied expectations match the live lease exactly.
Everything else fails closed. The ledger is only ever appended to a
superseded dry run stays visible as history instead of being rewritten which
keeps the cleanup audit trail append-only end to end.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
CLEANUP_TASK = "cleanup_post_merge_moot_lease"
CLEANUP_TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease"
REQUIRED_ROLE = "reconciler"
REQUIRED_PERMISSION = "gitea.pr.comment"
# The read-only assessment stays reachable under gitea.read for every role —
# the convention shared with cleanup_stale_review_decision_lock and
# cleanup_obsolete_reviewer_comment_lease — so any namespace can diagnose a
# stuck lease. Only the apply path demands CLEANUP_TASK + REQUIRED_ROLE.
ASSESSMENT_PERMISSION = "gitea.read"
_DRY_RUN_LEDGER: list[dict[str, Any]] = []
def _norm(value: Any) -> str:
return str(value or "").strip()
def _norm_comment_id(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def record_dry_run(
*,
pr_number: int,
repository_slug: str | None,
lease_moot: bool,
cleanup_allowed: bool,
session_id: str | None,
candidate_head: str | None,
lease_comment_id: Any,
recorded_at: datetime | None = None,
) -> dict[str, Any]:
"""Append one read-only assessment to the dry-run ledger.
Never rewrites or removes a prior entry: repeated dry runs accumulate and
``latest_dry_run`` returns the newest matching one.
"""
entry = {
"task": CLEANUP_TASK,
"pr_number": int(pr_number),
"repository_slug": _norm(repository_slug) or None,
"lease_moot": bool(lease_moot),
"cleanup_allowed": bool(cleanup_allowed),
"session_id": _norm(session_id) or None,
"candidate_head": _norm(candidate_head) or None,
"lease_comment_id": _norm_comment_id(lease_comment_id),
"recorded_at": (recorded_at or datetime.now(timezone.utc)).isoformat(),
}
_DRY_RUN_LEDGER.append(entry)
return dict(entry)
def dry_run_history() -> tuple[dict[str, Any], ...]:
"""Immutable view of every recorded dry run, oldest first."""
return tuple(dict(entry) for entry in _DRY_RUN_LEDGER)
def latest_dry_run(
*, pr_number: int, repository_slug: str | None
) -> dict[str, Any] | None:
"""Newest dry-run evidence for this repository + PR, or None."""
wanted_repo = _norm(repository_slug)
for entry in reversed(_DRY_RUN_LEDGER):
if entry["pr_number"] != int(pr_number):
continue
if _norm(entry.get("repository_slug")) != wanted_repo:
continue
return dict(entry)
return None
def _reset_for_testing() -> None:
"""Drop ledger state between tests. Never called by production paths."""
_DRY_RUN_LEDGER.clear()
def assess_apply_authorization(
*,
pr_number: int,
repository_slug: str | None,
resolved_task: str | None,
active_role_kind: str | None,
assessment: dict[str, Any],
evidence: dict[str, Any] | None,
expected_session_id: str | None = None,
expected_candidate_head: str | None = None,
expected_lease_comment_id: Any = None,
) -> dict[str, Any]:
"""Decide whether a moot-lease cleanup apply is authorized (fail closed).
Returns ``{"allowed", "reasons", "blocker_kind", "evidence_matched", ...}``.
``allowed`` is True only when every check passes; each failure contributes a
reason so the caller can report all of them together.
"""
reasons: list[str] = []
blocker_kind: str | None = None
def _block(kind: str, reason: str) -> None:
nonlocal blocker_kind
reasons.append(reason)
if blocker_kind is None:
blocker_kind = kind
# 1. Exact resolved cleanup task. Resolving any other task — including a
# sibling reconciler task — does not authorize this mutation.
if _norm(resolved_task) != CLEANUP_TASK:
_block(
"unresolved_cleanup_task",
"post-merge moot-lease cleanup requires the session to resolve "
f"task '{CLEANUP_TASK}' immediately before apply; resolved task is "
f"{resolved_task!r} (fail closed)",
)
# 2. Dedicated reconciler role, enforced independently of the permission.
if _norm(active_role_kind) != REQUIRED_ROLE:
_block(
"wrong_role",
f"profile role {active_role_kind!r} cannot apply post-merge "
f"moot-lease cleanup; required role is {REQUIRED_ROLE} even when "
f"{REQUIRED_PERMISSION} is present (fail closed)",
)
# 3. Canonical repository identity must be established, never inferred from
# request parameters.
if not _norm(repository_slug):
_block(
"repository_binding",
"no canonical repository identity could be established for the "
"cleanup target (fail closed)",
)
# 4. The live safety assessment must still say the lease is moot/cleanable.
if not assessment.get("is_moot") or not assessment.get("cleanup_allowed"):
_block(
"lease_not_moot",
"live assessment does not report a moot, cleanable lease on PR "
f"#{pr_number} (lease_moot={bool(assessment.get('is_moot'))}, "
f"cleanup_allowed={bool(assessment.get('cleanup_allowed'))}) "
"(fail closed)",
)
live = assessment.get("active_lease") or {}
live_session = _norm(live.get("session_id"))
live_head = _norm(live.get("candidate_head"))
live_comment_id = _norm_comment_id(live.get("comment_id"))
# 5. A lease missing identifying fields is malformed and unsafe to act on.
if not live_session or not live_head or live_comment_id is None:
_block(
"malformed_lease",
"active lease is malformed: session_id / candidate_head / "
"comment_id must all be present to authorize cleanup "
f"(session_id={live.get('session_id')!r}, "
f"candidate_head={live.get('candidate_head')!r}, "
f"comment_id={live.get('comment_id')!r}) (fail closed)",
)
# 6. Caller expectations, when supplied, must match the live lease exactly.
if expected_session_id is not None and _norm(expected_session_id) != live_session:
_block(
"lease_mismatch",
f"expected lease session {expected_session_id!r} does not match the "
f"live lease session {live.get('session_id')!r} (fail closed)",
)
if (
expected_candidate_head is not None
and _norm(expected_candidate_head) != live_head
):
_block(
"lease_mismatch",
f"expected candidate head {expected_candidate_head!r} does not "
f"match the live lease head {live.get('candidate_head')!r} "
"(fail closed)",
)
if expected_lease_comment_id is not None and (
_norm_comment_id(expected_lease_comment_id) != live_comment_id
):
_block(
"lease_mismatch",
f"expected lease marker {expected_lease_comment_id!r} does not "
f"match the live lease marker {live.get('comment_id')!r} "
"(fail closed)",
)
# 7. Matching dry-run evidence recorded earlier in this session.
evidence_matched = False
if evidence is None:
_block(
"missing_dry_run_evidence",
"no read-only dry run recorded for this repository and PR; run the "
"tool with apply=false and confirm lease_moot / cleanup_allowed "
"before applying (fail closed)",
)
elif not evidence.get("lease_moot") or not evidence.get("cleanup_allowed"):
_block(
"dry_run_not_allowed",
"recorded dry run did not report an allowed cleanup "
f"(lease_moot={bool(evidence.get('lease_moot'))}, "
f"cleanup_allowed={bool(evidence.get('cleanup_allowed'))}) "
"(fail closed)",
)
elif int(evidence.get("pr_number") or -1) != int(pr_number) or _norm(
evidence.get("repository_slug")
) != _norm(repository_slug):
_block(
"dry_run_mismatch",
"recorded dry run targets a different repository or PR "
f"({evidence.get('repository_slug')}#{evidence.get('pr_number')} vs "
f"{repository_slug}#{pr_number}) (fail closed)",
)
elif (
_norm(evidence.get("session_id")) != live_session
or _norm(evidence.get("candidate_head")) != live_head
or _norm_comment_id(evidence.get("lease_comment_id")) != live_comment_id
):
_block(
"superseded_lease",
"the lease changed after the recorded dry run (dry run: "
f"session={evidence.get('session_id')!r}, "
f"head={evidence.get('candidate_head')!r}, "
f"marker={evidence.get('lease_comment_id')!r}; live: "
f"session={live.get('session_id')!r}, "
f"head={live.get('candidate_head')!r}, "
f"marker={live.get('comment_id')!r}); re-run the dry run "
"(fail closed)",
)
else:
evidence_matched = True
return {
"allowed": not reasons,
"reasons": reasons,
"blocker_kind": blocker_kind,
"evidence_matched": evidence_matched,
"required_task": CLEANUP_TASK,
"required_role_kind": REQUIRED_ROLE,
"required_permission": REQUIRED_PERMISSION,
}
+213 -12
View File
@@ -41,6 +41,186 @@ _DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "
UPDATE_STYLE_MERGE = "merge"
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
# ── Commit check classifications (#751) ──────────────────────────────────
# Gitea's *combined* commit status reports ``state: pending`` both when a real
# check is executing and when the status-context collection is empty. Reading
# ``state`` alone therefore cannot distinguish "CI is running" from "no CI
# exists", which permanently blocks a merge-ready PR that no check will ever
# report on. These classifications are derived from the actual context
# collection plus the live branch-protection policy.
CHECKS_SUCCESS = "success"
CHECKS_FAILURE = "failure"
CHECKS_PENDING = "pending"
CHECKS_NONE = "none" # configured/produced nothing
CHECKS_NOT_REQUIRED = "not_required" # protection does not require checks
CHECKS_MISSING_REQUIRED = "missing_required" # required contexts have no result
CHECKS_UNKNOWN = "unknown" # indeterminable — fail closed
# Values that permit merge_now when checks are required.
_CHECKS_OK = frozenset({"success", "passed", "ok", "skipped", "not_required"})
# Raw per-context state vocabularies reported by Gitea.
_CTX_SUCCESS = frozenset({"success", "passed", "ok"})
_CTX_FAILURE = frozenset({"failure", "failed", "error", "cancelled", "canceled"})
_CTX_PENDING = frozenset({"pending", "running", "queued", "expected"})
_CTX_SKIPPED = frozenset({"skipped", "neutral"})
def _normalize_context_rows(statuses: Any) -> list[dict[str, str]]:
"""Reduce a raw status collection to newest-wins ``{context, state}`` rows.
Gitea returns the status collection newest-first, so the first row seen for
a context wins. Rows without a usable state are discarded rather than being
silently treated as passing.
"""
rows: list[dict[str, str]] = []
seen: set[str] = set()
if not isinstance(statuses, list):
return rows
for raw in statuses:
if not isinstance(raw, dict):
continue
context = (raw.get("context") or raw.get("name") or "").strip()
state = (raw.get("status") or raw.get("state") or "").strip().lower()
if not state:
continue
key = context or f"__unnamed__{len(rows)}"
if key in seen:
continue
seen.add(key)
rows.append({"context": context, "state": state})
return rows
def _aggregate_context_states(rows: list[dict[str, str]]) -> str:
"""Fail-closed aggregate: failure > pending > unknown-state > success."""
states = {row["state"] for row in rows}
if states & _CTX_FAILURE:
return CHECKS_FAILURE
if states & _CTX_PENDING:
return CHECKS_PENDING
unresolved = states - _CTX_SUCCESS - _CTX_SKIPPED
if unresolved:
# An unrecognized context state must never read as success.
return CHECKS_UNKNOWN
return CHECKS_SUCCESS
def classify_commit_checks(
*,
combined_state: str | None = None,
statuses: Any = None,
checks_enabled: bool | None = None,
required_contexts: Any = None,
policy_determinable: bool = True,
status_determinable: bool = True,
) -> dict[str, Any]:
"""Classify head checks from live evidence (#751).
``combined_state`` is deliberately **not** authoritative: it is recorded for
observability but never used to infer that CI is executing. The context
collection and the live protection policy decide.
Returns ``checks_status`` (one of the ``CHECKS_*`` values), the derived
``checks_required`` flag, and structured ``reasons``.
"""
reasons: list[str] = []
rows = _normalize_context_rows(statuses)
required = [
str(ctx).strip()
for ctx in (required_contexts or [])
if str(ctx or "").strip()
]
observed_combined = (combined_state or "").strip().lower() or None
result: dict[str, Any] = {
"checks_status": CHECKS_UNKNOWN,
"checks_required": True,
"combined_state": observed_combined,
"context_count": len(rows),
"observed_contexts": [row["context"] for row in rows],
"required_contexts": required,
"missing_required_contexts": [],
"policy_determinable": bool(policy_determinable),
"status_determinable": bool(status_determinable),
"reasons": reasons,
}
# Policy unreadable → never assume checks are optional.
if not policy_determinable:
reasons.append(
"branch-protection check policy could not be read; cannot prove "
"whether status checks are required (fail closed)"
)
return result
if checks_enabled is False:
result["checks_required"] = False
result["checks_status"] = CHECKS_NOT_REQUIRED
reasons.append(
"live branch protection does not require status checks for the base "
"branch; head status contexts do not gate merge"
)
return result
if checks_enabled is None:
reasons.append(
"branch-protection status-check requirement is indeterminate "
"(fail closed)"
)
return result
# Checks are required from here on.
if not status_determinable:
reasons.append(
"head commit status collection could not be read while branch "
"protection requires status checks (fail closed)"
)
return result
if required:
by_context = {row["context"]: row["state"] for row in rows if row["context"]}
missing = [ctx for ctx in required if ctx not in by_context]
if missing:
result["missing_required_contexts"] = missing
result["checks_status"] = CHECKS_MISSING_REQUIRED
reasons.append(
"branch protection requires status context(s) "
f"{', '.join(missing)} but no matching status result exists at "
"the head commit (fail closed)"
)
return result
matched = [
{"context": ctx, "state": by_context[ctx]} for ctx in required
]
result["checks_status"] = _aggregate_context_states(matched)
reasons.append(
f"evaluated {len(matched)} required status context(s) from live "
"branch protection; unrelated contexts were ignored"
)
return result
# Status checks enabled with no specific required contexts configured.
if not rows:
result["checks_status"] = CHECKS_NONE
reasons.append(
"branch protection enables status checks but no status context was "
"produced for the head commit"
)
if observed_combined in _CTX_PENDING:
reasons.append(
f"combined commit state '{observed_combined}' does not indicate "
"executing CI because the status-context collection is empty"
)
return result
result["checks_status"] = _aggregate_context_states(rows)
reasons.append(
f"aggregated {len(rows)} reported status context(s); branch protection "
"configures no explicit required-context list"
)
return result
def _normalize_sha(value: str | None) -> str | None:
text = (value or "").strip().lower()
@@ -120,6 +300,7 @@ def assess_pr_sync_status(
"branch_protection_requires_current_base": requires_current,
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
"checks_status": checks,
"checks_required": bool(checks_required),
"active_locks_and_leases": {
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
@@ -258,21 +439,41 @@ def assess_pr_sync_status(
result["recommended_next_action"] = ACTION_BLOCKED
return result
# ── Checks gate for merge_now ────────────────────────────────────────
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
if checks in ("pending", "running", "queued"):
# ── Checks gate for merge_now (#751) ─────────────────────────────────
# ``checks_required`` is derived from the live branch-protection policy by
# the production caller. When protection does not require status checks,
# head contexts cannot gate the merge and this whole gate is skipped.
if not checks_required:
reasons.append(
"live branch protection does not require status checks; head check "
f"state ({checks}) does not gate merge"
)
elif checks not in _CHECKS_OK:
if checks in _CTX_PENDING:
reasons.append(f"required checks are not finished (status={checks})")
result["recommended_next_action"] = ACTION_BLOCKED
return result
if checks in ("failure", "failed", "error", "cancelled"):
elif checks in _CTX_FAILURE:
reasons.append(f"required checks failed (status={checks})")
result["recommended_next_action"] = ACTION_BLOCKED
return result
# unknown — fail closed when checks_required
if checks == "unknown":
elif checks == CHECKS_MISSING_REQUIRED:
reasons.append(
"branch protection configures required status context(s) but no "
"matching status result exists at the current head (fail closed)"
)
elif checks == CHECKS_NONE:
reasons.append(
"branch protection requires status checks but no status context "
"was produced for the current head (fail closed); an empty "
"status collection is not executing CI"
)
elif checks == CHECKS_UNKNOWN:
reasons.append("checks status unknown (fail closed)")
result["recommended_next_action"] = ACTION_BLOCKED
return result
else:
# Unrecognized vocabulary must never fall through to merge_now.
reasons.append(
f"unrecognized checks status '{checks}' cannot prove required "
"checks passed (fail closed)"
)
result["recommended_next_action"] = ACTION_BLOCKED
return result
# ── Ready to merge without update ────────────────────────────────────
# Includes: current with approval; outdated when update is NOT required.
+2 -1
View File
@@ -36,7 +36,8 @@ _TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
# The reviewer/merger PR-lease TTL lives in reviewer_pr_lease.LEASE_TTL_MINUTES
# (#747). A second copy here had no readers and could only drift out of sync.
def _parse_timestamp(value: str | None) -> datetime | None:
+46 -7
View File
@@ -29,9 +29,19 @@ _ACTIVE_PHASES = frozenset({
"adopted",
})
DEFAULT_LEASE_TTL_MINUTES = 120
STALE_WARNING_MINUTES = 30
RECLAIMABLE_MINUTES = 60
# Reviewer and merger PR leases use a short *sliding* window (#747): a lease
# expires 10 minutes after its last heartbeat, and every heartbeat slides the
# expiry forward. An actively heartbeating session is never evicted, while a
# dead session releases its hold in at most one TTL instead of the two hours
# the previous fixed 120-minute expiry allowed.
LEASE_TTL_MINUTES = 10
# Renewal is named separately from acquisition so the slide amount is tunable
# without silently re-defining how long a fresh lease lives.
LEASE_RENEWAL_MINUTES = 10
# Retained for callers that imported the pre-#747 name.
DEFAULT_LEASE_TTL_MINUTES = LEASE_TTL_MINUTES
# Warn at half the window, while the owner can still heartbeat and recover.
STALE_WARNING_MINUTES = 5
_SESSION_LEASE: dict[str, Any] | None = None
@@ -80,10 +90,19 @@ def format_lease_body(
target_branch_sha: str | None,
last_activity: datetime | None = None,
expires_at: datetime | None = None,
ttl_minutes: int = LEASE_TTL_MINUTES,
blocker: str = "none",
) -> str:
"""Serialize a lease marker.
Every write of this marker acquisition, heartbeat, adoption re-derives
``expires_at`` from the moment of the write, which is what makes the TTL
slide (#747). Callers renewing an existing lease pass
``ttl_minutes=LEASE_RENEWAL_MINUTES``; an explicit ``expires_at`` still
wins so a lease can be minted with a deliberate window.
"""
now = last_activity or datetime.now(timezone.utc)
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
expires = expires_at or (now + timedelta(minutes=ttl_minutes))
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
@@ -169,8 +188,30 @@ def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
return (now - last).total_seconds() / 60.0
def lease_seconds_remaining(lease: dict, *, now: datetime | None = None) -> int | None:
"""Seconds until *lease* expires, clamped at 0; ``None`` if unparsable.
Lets diagnostics distinguish "held and live" from "held and dying" (#747)
rather than only reporting that a lease exists.
"""
expires_at = _parse_timestamp(lease.get("expires_at"))
if not expires_at:
return None
now = now or datetime.now(timezone.utc)
return max(0, int((expires_at - now).total_seconds()))
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
"""Return active, stale_warning, reclaimable, expired, or terminal."""
"""Return active, stale_warning, expired, or terminal.
Expiry is the only takeover gate (#747). The pre-#747 ``reclaimable`` band
sat between "stale" and "expired" and made a dead lease wait out a second
timer before anyone could reclaim it. Under a sliding TTL that band is also
unreachable: a heartbeat stamps ``last_activity`` and ``expires_at``
together, so a lease idle for a full TTL is already expired. Foreign
expired leases are handled by the ``foreign_expired`` classification, which
carries the same sanctioned release next-action the old tier did.
"""
now = now or datetime.now(timezone.utc)
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_PHASES:
@@ -180,8 +221,6 @@ def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str
minutes = _minutes_since_activity(lease, now=now)
if minutes is None:
return "active"
if minutes >= RECLAIMABLE_MINUTES:
return "reclaimable"
if minutes >= STALE_WARNING_MINUTES:
return "stale_warning"
return "active"
+8
View File
@@ -87,6 +87,11 @@ RECONCILER_TASKS = frozenset({
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
"delete_branch",
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned; the
# apply path posts a terminal lease marker. Kept in step with
# task_capability_map so map and router cannot disagree (#723 defect A).
"cleanup_post_merge_moot_lease",
"gitea_cleanup_post_merge_moot_lease",
"reconcile_already_landed_pr",
"reconcile_already_landed",
"reconcile-landed-pr",
@@ -118,6 +123,9 @@ TASK_REQUIRED_ROLE = {
"reconcile_already_landed": "reconciler",
"reconcile-landed-pr": "reconciler",
"cleanup_merged_pr_branch": "reconciler",
# #745: post-merge moot reviewer-lease cleanup (canonical task + tool alias).
"cleanup_post_merge_moot_lease": "reconciler",
"gitea_cleanup_post_merge_moot_lease": "reconciler",
# #309: reconciler tasks close already-landed PRs/issues only.
"reconcile_close_landed_pr": "reconciler",
"reconcile_close_landed_issue": "reconciler",
+499
View File
@@ -0,0 +1,499 @@
"""Fail-closed guard against manual MCP daemon process killing (#630).
Workflow recovery must use sanctioned reconnect/restart paths only: host
auto-reconnect, an operator-owned restart, or the documented client relaunch. A
session that instead runs ``pkill -f mcp_server.py`` has manipulated the very
host processes its own proof depends on.
Incident origin: a session ran ``ps aux | grep mcp_server``, then
``pkill -f mcp_server.py``, waited for the IDE to respawn the daemons, called
MCP tools, and closed issue #601. Nothing distinguished that closure from one
performed over a sanctioned runtime, and unrelated namespaces may have been
killed as collateral damage.
Partial detection already existed ``native_mcp_preference.classify_command_path``
flags ``kill``/``pkill`` near ``mcp_server`` as an MCP-server touch, and
``review_workflow_boundary`` classifies a pre-review ``pkill`` as MCP repair
activity but neither wrote a durable marker nor failed closed on the
review / merge / close mutations that followed.
This module mirrors ``stable_branch_push_guard`` (#671) deliberately: same
contamination-marker shape, same gated-task set, same reconciler-only clear.
Like that guard it is **pure** callers gather the raw facts (the proposed
command line, known MCP pids, the durable marker, the process environment) and
pass them in, so one implementation serves prompts, MCP gates and tests.
Nothing here kills, spawns or inspects a process, performs I/O, or reads
durable state.
Design rules honoured (from the #630 acceptance criteria):
* Detect ``pkill -f mcp_server.py``, ``pkill -f gitea_mcp_server``, broad
``pkill -f mcp``, ``killall`` equivalents, and ``kill <pid>`` of a known MCP
daemon pid.
* Detect a pattern broad enough to take unrelated namespaces as collateral
damage (``pkill -f python``) even when it never names MCP.
* Never flag read-only inspection (``ps aux | grep mcp_server``), a sanctioned
client reconnect, or process management unrelated to the daemons. A bare
``kill <pid>`` with no MCP linkage is reported as *ambiguous*, never as
contamination, so ordinary subprocess work is not false-blocked.
* Never accept operator authorization from a tool argument. Authorization is
read from the process environment only which an in-session worker cannot
set for an already-running daemon. A self-assertable ``operator_authorized``
argument was rejected in the PR #710 review (finding F1) and is not
reintroduced here.
* Contamination is never clearable by the same worker session; only a
reconciler (audit) role may clear it or bypass the gate.
"""
from __future__ import annotations
import os
import re
from typing import Any, Iterable
# Single source of truth for both the redactor and the gated-mutation set: the
# #671 guard already owns them, so the two contamination models can never drift
# apart on which mutations a contaminated session may still perform.
from stable_branch_push_guard import ( # noqa: F401 (CONTAMINATION_GATED_TASKS re-exported)
CONTAMINATION_GATED_TASKS,
redact_command,
)
CONTAMINATION_KIND = "manual_daemon_kill"
#: The session killed (or pattern-matched) an MCP daemon process directly.
REASON_MANUAL_DAEMON_KILL = "manual_daemon_kill"
#: The pattern was broad enough to sweep unrelated MCP namespaces.
REASON_BROAD_PROCESS_KILL = "broad_process_kill"
#: Operator authorization is read from this environment variable ONLY. It is
#: set outside the workflow session by the operator who owns host maintenance;
#: an in-session worker cannot set it for an already-running daemon. The value
#: is an audit reference (ticket, change id, or operator note) and is recorded
#: on the marker. Never accept this from a tool argument (#710 finding F1).
OPERATOR_AUTHORIZATION_ENV = "GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION"
REMEDIATION = (
"Manual MCP daemon process killing is not a sanctioned workflow recovery. "
"Stop, leave the host processes alone, and recover through the client "
"reconnect / relaunch path (see docs/mcp-namespace-eof-recovery.md) or an "
"operator-owned restart. This session is workflow-contaminated until a "
"reconciler audits it; review, merge, close and completion mutations fail "
"closed until then."
)
# ── command tokenising ────────────────────────────────────────────────────────
# Split a compound command line into simple commands on shell separators so
# ``ps aux | grep mcp_server`` is analysed segment by segment and its harmless
# inspection half never reaches the kill classifier.
_SEGMENT_SPLIT_RE = re.compile(r"(?:\|\||&&|\||;|\n)")
_KILL_VERBS = frozenset({"kill", "pkill", "killall"})
# Tokens that may legitimately precede the kill verb in command position.
_COMMAND_PREFIXES = frozenset({
"sudo", "command", "exec", "time", "nohup", "env", "builtin",
})
# Matches the daemon process names: ``mcp_server``/``mcp-server`` (optionally
# ``gitea_``-prefixed, optionally ``.py``) or a standalone ``mcp`` token.
# ``mcpfoo`` deliberately does not match.
_MCP_TARGET_RE = re.compile(
r"(?:gitea[_-])?mcp[_-]?server|(?<![\w-])mcp(?![\w-])",
re.IGNORECASE,
)
# Patterns broad enough that matching them would kill unrelated MCP namespaces
# (and unrelated tooling) as collateral damage.
_BROAD_PATTERN_RE = re.compile(
r"^(?:python[\d.]*|node|uv|venv|java|ruby|perl|\.|\.\*|\*|%)$",
re.IGNORECASE,
)
# ``pkill``/``killall`` flags that consume the following token as their value,
# so it is not mistaken for a process pattern.
_VALUE_FLAGS = frozenset({
"-u", "-U", "-g", "-G", "-P", "-t", "-s", "-F", "-M", "-N", "-r",
"--signal", "--uid", "--euid", "--group", "--parent", "--session",
"--terminal", "--ns", "--nslist", "--pidfile", "--older",
})
# Sanctioned recovery language — informational only. Its presence never
# suppresses a detected kill; a session that describes a reconnect *and* runs
# ``pkill`` is still contaminated.
_SANCTIONED_RECOVERY_RE = re.compile(
r"/mcp\s+reconnect|client\s+reconnect|reconnect\s+the\s+(?:ide|client)|"
r"relaunch\s+the\s+(?:ide|client)|ide\s+restart|operator[- ]owned\s+restart",
re.IGNORECASE,
)
def _clean(value: str | None) -> str:
return (value or "").strip()
def _split_segments(command: str) -> list[str]:
return [seg for seg in _SEGMENT_SPLIT_RE.split(command) if seg.strip()]
def is_sanctioned_recovery(text: str | None) -> bool:
"""True when *text* describes a sanctioned reconnect/restart path.
Informational only: this never downgrades a detected process kill.
"""
return bool(_SANCTIONED_RECOVERY_RE.search(_clean(text)))
# ── kill classification ───────────────────────────────────────────────────────
def _analyse_kill_segment(
segment: str,
*,
mcp_pids: frozenset[str],
) -> dict[str, Any] | None:
"""Classify one command segment, or return None when it is not a kill."""
tokens = segment.split()
idx = 0
# Skip env assignments and harmless command prefixes (``sudo pkill ...``).
while idx < len(tokens) and (tokens[idx] in _COMMAND_PREFIXES or "=" in tokens[idx]):
idx += 1
if idx >= len(tokens):
return None
verb = os.path.basename(tokens[idx]).lower()
if verb not in _KILL_VERBS:
return None
operands: list[str] = []
skip_next = False
for token in tokens[idx + 1:]:
if skip_next:
skip_next = False
continue
if token.startswith("-"):
if token in _VALUE_FLAGS:
skip_next = True
continue
operands.append(token)
names_mcp = bool(_MCP_TARGET_RE.search(segment))
def _result(
*,
reason_class: str | None,
contamination: bool,
ambiguous: bool,
reason: str,
) -> dict[str, Any]:
return {
"verb": verb,
"operands": operands,
"reason_class": reason_class,
"contamination": contamination,
"ambiguous": ambiguous,
"reason": reason,
}
if verb in {"pkill", "killall"}:
if names_mcp:
return _result(
reason_class=REASON_MANUAL_DAEMON_KILL,
contamination=True,
ambiguous=False,
reason=(
f"'{verb}' targets the MCP daemon process pattern; this is "
"manual daemon killing, not a sanctioned recovery"
),
)
broad = [op for op in operands if _BROAD_PATTERN_RE.match(op)]
if broad:
return _result(
reason_class=REASON_BROAD_PROCESS_KILL,
contamination=True,
ambiguous=False,
reason=(
f"'{verb}' pattern {broad[0]!r} is broad enough to kill "
"unrelated MCP namespaces as collateral damage"
),
)
if not operands:
return _result(
reason_class=None,
contamination=False,
ambiguous=True,
reason=f"'{verb}' with no resolvable pattern; target unknown",
)
return _result(
reason_class=None,
contamination=False,
ambiguous=False,
reason=(
f"'{verb}' targets {operands!r}, which does not name an MCP "
"daemon or a broad pattern"
),
)
# ``kill`` — pid-addressed.
pids = [op for op in operands if op.isdigit()]
hits = sorted(set(pids) & mcp_pids, key=int)
if hits:
return _result(
reason_class=REASON_MANUAL_DAEMON_KILL,
contamination=True,
ambiguous=False,
reason=(
"'kill' targets known MCP daemon pid(s) "
f"{', '.join(hits)}; this is manual daemon killing"
),
)
if names_mcp:
return _result(
reason_class=REASON_MANUAL_DAEMON_KILL,
contamination=True,
ambiguous=False,
reason="'kill' resolves its target from an MCP daemon process lookup",
)
if not pids:
return _result(
reason_class=None,
contamination=False,
ambiguous=True,
reason="'kill' with no resolvable numeric pid; target unknown",
)
return _result(
reason_class=None,
contamination=False,
ambiguous=True,
reason=(
f"'kill' targets pid(s) {', '.join(pids)}, which are not known MCP "
"daemon pids; pass mcp_pids to resolve the ambiguity"
),
)
def classify_recovery_command(
command: str | None = None,
*,
mcp_pids: Iterable[Any] | None = None,
) -> dict[str, Any]:
"""Classify a proposed command for manual MCP daemon kill intent (#630).
Pure classification; operator authorization is applied separately by
:func:`assess_recovery_command`.
"""
text = _clean(command)
pid_set = frozenset(
str(pid).strip() for pid in (mcp_pids or []) if str(pid).strip()
)
segments: list[dict[str, Any]] = []
for raw_segment in _split_segments(text):
analysed = _analyse_kill_segment(raw_segment, mcp_pids=pid_set)
if analysed is not None:
analysed["segment"] = redact_command(raw_segment)
segments.append(analysed)
contaminating = [seg for seg in segments if seg["contamination"]]
return {
"command_present": bool(text),
"redacted_command": redact_command(text),
"process_kill": bool(segments),
"contamination": bool(contaminating),
"reason_class": contaminating[0]["reason_class"] if contaminating else None,
"ambiguous": bool(
not contaminating and any(seg["ambiguous"] for seg in segments)
),
"sanctioned_recovery": is_sanctioned_recovery(text),
"segments": segments,
"reasons": [seg["reason"] for seg in segments],
"known_mcp_pids": sorted(pid_set, key=lambda p: int(p) if p.isdigit() else 0),
}
# ── operator authorization ────────────────────────────────────────────────────
def operator_authorization(env: dict[str, str] | None = None) -> dict[str, Any]:
"""Read operator authorization for host daemon maintenance (#630 non-goal 1).
Authorization comes from :data:`OPERATOR_AUTHORIZATION_ENV` in the process
environment and from nowhere else. A worker session cannot set an
environment variable for an already-running daemon, so this cannot be
self-asserted the way a tool argument could be (#710 finding F1).
"""
source = env if env is not None else os.environ
reference = _clean(source.get(OPERATOR_AUTHORIZATION_ENV))
return {
"authorized": bool(reference),
"reference": reference or None,
"source": OPERATOR_AUTHORIZATION_ENV if reference else None,
"self_assertable": False,
}
def assess_recovery_command(
command: str | None = None,
*,
mcp_pids: Iterable[Any] | None = None,
env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Classify *command* and apply operator authorization (#630 AC1/AC2)."""
classification = classify_recovery_command(command, mcp_pids=mcp_pids)
authorization = operator_authorization(env)
detected = classification["contamination"]
contaminated = detected and not authorization["authorized"]
return {
"classification": classification,
"authorization": authorization,
"contaminated": contaminated,
"authorized_bypass": bool(detected and authorization["authorized"]),
"remediation": REMEDIATION if contaminated else None,
}
# ── contamination record + gate ───────────────────────────────────────────────
def build_contamination_record(
*,
reason_class: str,
command_redacted: str | None = None,
session_id: str | None = None,
remote: str | None = None,
role: str | None = None,
detail: str | None = None,
authorization_reference: str | None = None,
) -> dict[str, Any]:
"""Build the durable contamination marker payload (redacted, audit-safe).
``reason_class`` is :data:`REASON_MANUAL_DAEMON_KILL` or
:data:`REASON_BROAD_PROCESS_KILL`. The command is stored already redacted;
secrets never persist on the marker.
"""
return {
"kind": CONTAMINATION_KIND,
"reason_class": _clean(reason_class) or REASON_MANUAL_DAEMON_KILL,
"command_summary": redact_command(command_redacted),
"session_id": _clean(session_id) or None,
"remote": _clean(remote) or None,
"role": _clean(role) or None,
"detail": _clean(detail) or None,
"authorization_reference": _clean(authorization_reference) or None,
"cleared_by_reconciler": False,
}
def assess_contamination_gate(
marker: dict[str, Any] | None,
*,
task: str | None,
actual_role: str | None,
) -> dict[str, Any]:
"""Fail closed on gated mutations while a contamination marker is live (#630 AC3).
* No marker allowed.
* Reconciler (audit) role allowed (the sanctioned inspect/clear path).
* Marker present + ``task`` in :data:`CONTAMINATION_GATED_TASKS` blocked.
* Marker present + non-gated task (``comment_issue``, ``lock_issue``)
allowed, so the contaminated worker can still post the durable audit
comment and hand off.
"""
if not marker or marker.get("cleared_by_reconciler"):
return {"block": False, "reasons": [], "task": task}
role = _clean(actual_role).lower()
if role == "reconciler":
return {
"block": False,
"reasons": [],
"task": task,
"detail": "reconciler audit path is exempt from the contamination gate",
}
task_name = _clean(task)
if task_name and task_name in CONTAMINATION_GATED_TASKS:
summary = marker.get("command_summary") or marker.get("detail") or "(no summary)"
reason_class = marker.get("reason_class") or REASON_MANUAL_DAEMON_KILL
return {
"block": True,
"reasons": [
f"session is workflow-contaminated ({reason_class}): {summary}. "
f"'{task_name}' is blocked until a reconciler audits and clears "
"the contamination. " + REMEDIATION
],
"task": task_name,
}
return {"block": False, "reasons": [], "task": task_name or None}
def format_contamination_gate_error(gate: dict[str, Any]) -> str:
"""Single RuntimeError message for MCP mutation gates."""
reasons = "; ".join(gate.get("reasons") or ["session workflow-contaminated"])
return f"Runtime-recovery contamination gate (#630): {reasons}"
# ── final-report rules ────────────────────────────────────────────────────────
# Claims that assert a clean session. While a marker is live these are false and
# must be rejected rather than merely downgraded.
_CLEAN_CLAIM_RE = re.compile(
r"\bclean\s+session\b|\bsession\s+(?:is|was|remains)\s+clean\b|"
r"\bno\s+contamination\b|\buncontaminated\b|\bcontamination\s*[:=]\s*none\b|"
r"\bworkflow[- ]clean\b|\bno\s+workflow\s+contamination\b",
re.IGNORECASE,
)
# Language that actually surfaces the contamination to a reader.
_SURFACED_RE = re.compile(
r"manual[_ ]daemon[_ ]kill|broad[_ ]process[_ ]kill|daemon\s+process\s+kill|"
r"contaminated\s+recovery|runtime[- ]recovery\s+contamination|"
r"workflow[- ]contaminated",
re.IGNORECASE,
)
def assess_final_report_claim(
report_text: str | None,
marker: dict[str, Any] | None,
) -> dict[str, Any]:
"""Reject clean-session claims while contaminated (#630 scope item 4).
A live marker imposes two obligations on the final report: it must surface
the contaminated recovery explicitly, and it must not claim the session is
clean. Either failure blocks.
"""
if not marker or marker.get("cleared_by_reconciler"):
return {
"block": False,
"reasons": [],
"contaminated": False,
"surfaced": None,
"clean_claim": False,
}
text = _clean(report_text)
surfaced = bool(_SURFACED_RE.search(text))
clean_claim = bool(_CLEAN_CLAIM_RE.search(text))
reasons: list[str] = []
if clean_claim:
reasons.append(
"final report claims a clean session while a live "
f"{marker.get('reason_class') or CONTAMINATION_KIND} contamination "
"marker exists; the claim is false and must be removed"
)
if not surfaced:
reasons.append(
"final report does not surface the contaminated runtime recovery; "
"the report must state that MCP daemon processes were manually "
"killed and that the session awaits a reconciler audit"
)
return {
"block": bool(reasons),
"reasons": reasons,
"contaminated": True,
"surfaced": surfaced,
"clean_claim": clean_claim,
"reason_class": marker.get("reason_class"),
}
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
usage: scripts/promote-stable-runtime [--root <path>] [--promoted <sha>] \
[--source-branch <branch>] [--source-pr <n>] \
[--restart-method <text>] [--rollback <text>] \
[--health-check-proof <text>] \
[--identity-proof <text>] [--profile-proof <text>] \
[--workspace-proof <text>] \
[--mutation-capability-proof <text>]
Emit and validate a stable-control-runtime promotion record (#615).
This helper is READ-ONLY. It never fetches, merges, restarts, reloads, or kills
anything: promotion itself is an operator action documented in
docs/stable-runtime-promotion-runbook.md. The helper reads the current runtime
state, assembles the required record, validates it with
stable_control_runtime.assess_promotion_record(), and prints it for the operator
to act on and archive.
Defaults:
--root the repository root containing this script
--promoted HEAD of that root
Exit status is non-zero when the assembled record is incomplete, so a promotion
cannot be recorded without its proof fields.
Example:
scripts/promote-stable-runtime \
--source-branch feat/issue-615-runtime-mode-enforcement \
--source-pr 770 \
--restart-method "IDE client reconnect (/mcp)" \
--rollback "git -C <root> merge --ff-only <previous-sha>; reconnect client" \
--health-check-proof "gitea_assess_mcp_namespace_health: all four healthy" \
--identity-proof "gitea_whoami per namespace" \
--profile-proof "gitea_get_runtime_context per namespace" \
--workspace-proof "process root == canonical root; clean" \
--mutation-capability-proof "gitea_resolve_task_capability: allowed"
EOF
}
ROOT=""
PROMOTED=""
SOURCE_BRANCH=""
SOURCE_PR=""
RESTART_METHOD=""
ROLLBACK=""
HEALTH_PROOF=""
IDENTITY_PROOF=""
PROFILE_PROOF=""
WORKSPACE_PROOF=""
CAPABILITY_PROOF=""
while [[ $# -gt 0 ]]; do
case "$1" in
--root) ROOT="${2:-}"; shift 2 ;;
--promoted) PROMOTED="${2:-}"; shift 2 ;;
--source-branch) SOURCE_BRANCH="${2:-}"; shift 2 ;;
--source-pr) SOURCE_PR="${2:-}"; shift 2 ;;
--restart-method) RESTART_METHOD="${2:-}"; shift 2 ;;
--rollback) ROLLBACK="${2:-}"; shift 2 ;;
--health-check-proof) HEALTH_PROOF="${2:-}"; shift 2 ;;
--identity-proof) IDENTITY_PROOF="${2:-}"; shift 2 ;;
--profile-proof) PROFILE_PROOF="${2:-}"; shift 2 ;;
--workspace-proof) WORKSPACE_PROOF="${2:-}"; shift 2 ;;
--mutation-capability-proof) CAPABILITY_PROOF="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
if ! git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then
echo "error: '$ROOT' is not a git checkout; cannot read runtime SHAs" >&2
exit 1
fi
PREVIOUS="${GITEA_MCP_PREVIOUS_RUNTIME_SHA:-}"
if [[ -z "$PREVIOUS" ]]; then
# The runtime the operator is replacing. Best-effort: the commit master
# pointed at before the fast-forward, recorded in the reflog.
PREVIOUS="$(git -C "$ROOT" rev-parse 'master@{1}' 2>/dev/null || true)"
fi
PROMOTED="${PROMOTED:-$(git -C "$ROOT" rev-parse HEAD)}"
BRANCH="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
DIRTY="$(git -C "$ROOT" status --porcelain | wc -l | tr -d ' ')"
STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -z "$WORKSPACE_PROOF" ]]; then
WORKSPACE_PROOF="root=$ROOT branch=$BRANCH dirty_files=$DIRTY"
fi
if [[ -z "$ROLLBACK" && -n "$PREVIOUS" ]]; then
ROLLBACK="git -C $ROOT merge --ff-only $PREVIOUS (or checkout $PREVIOUS), then reload the runtime by the same sanctioned method and re-prove every namespace"
fi
cat <<EOF
# Stable control runtime promotion record (#615)
# Generated $STAMP by scripts/promote-stable-runtime (read-only)
previous_runtime_sha: ${PREVIOUS:-<MISSING: record the SHA the runtime served before promotion>}
promoted_runtime_sha: ${PROMOTED}
source_branch: ${SOURCE_BRANCH:-<MISSING: pass --source-branch>}
source_pr: ${SOURCE_PR:-<MISSING: pass --source-pr>}
restart_method: ${RESTART_METHOD:-<MISSING: pass --restart-method>}
health_check_proof: ${HEALTH_PROOF:-<MISSING: pass --health-check-proof>}
identity_proof: ${IDENTITY_PROOF:-<MISSING: pass --identity-proof>}
profile_proof: ${PROFILE_PROOF:-<MISSING: pass --profile-proof>}
workspace_proof: ${WORKSPACE_PROOF}
mutation_capability_proof: ${CAPABILITY_PROOF:-<MISSING: pass --mutation-capability-proof>}
rollback_instructions: ${ROLLBACK:-<MISSING: pass --rollback>}
EOF
if [[ "$DIRTY" != "0" ]]; then
{
echo
echo "WARNING: the stable checkout has $DIRTY dirty file(s); a dirty stable"
echo " runtime is itself a mutation blocker (dirty_stable_runtime_checkout)."
} >&2
fi
PYTHON_BIN="${PYTHON_BIN:-python3}"
RECORD_JSON="$(
ROOT="$ROOT" \
PREVIOUS="$PREVIOUS" PROMOTED="$PROMOTED" \
SOURCE_BRANCH="$SOURCE_BRANCH" SOURCE_PR="$SOURCE_PR" \
RESTART_METHOD="$RESTART_METHOD" HEALTH_PROOF="$HEALTH_PROOF" \
IDENTITY_PROOF="$IDENTITY_PROOF" PROFILE_PROOF="$PROFILE_PROOF" \
WORKSPACE_PROOF="$WORKSPACE_PROOF" CAPABILITY_PROOF="$CAPABILITY_PROOF" \
ROLLBACK="$ROLLBACK" \
"$PYTHON_BIN" -c '
import json
import os
print(json.dumps({
"previous_runtime_sha": os.environ.get("PREVIOUS", ""),
"promoted_runtime_sha": os.environ.get("PROMOTED", ""),
"source_branch": os.environ.get("SOURCE_BRANCH", ""),
"source_pr": os.environ.get("SOURCE_PR", ""),
"restart_method": os.environ.get("RESTART_METHOD", ""),
"health_check_proof": os.environ.get("HEALTH_PROOF", ""),
"identity_proof": os.environ.get("IDENTITY_PROOF", ""),
"profile_proof": os.environ.get("PROFILE_PROOF", ""),
"workspace_proof": os.environ.get("WORKSPACE_PROOF", ""),
"mutation_capability_proof": os.environ.get("CAPABILITY_PROOF", ""),
"rollback_instructions": os.environ.get("ROLLBACK", ""),
}))
'
)"
RECORD_JSON="$RECORD_JSON" ROOT="$ROOT" "$PYTHON_BIN" -c '
import json
import os
import sys
sys.path.insert(0, os.environ["ROOT"])
import stable_control_runtime as scr
record = json.loads(os.environ["RECORD_JSON"])
assessment = scr.assess_promotion_record(record)
print()
print("validation:", json.dumps(assessment, indent=2))
print()
if not assessment["valid"]:
print("Promotion record is INCOMPLETE - do not archive it as a promotion.")
sys.exit(1)
print("Promotion record is complete. Archive it on the tracking issue.")
'
+907
View File
@@ -0,0 +1,907 @@
"""Self-propagating canonical handoffs through final controller closure (#626).
#494-#507 defined the canonical ledger, next-action comments, comment
validation, the controller acceptance gate, and the Canonical Thread Handoff
(CTH) shape. What none of them enforce is the *chain*: that every actor
consumes exactly one canonical handoff, performs exactly one authorized role,
records the result durably in Gitea, and emits the next complete handoff until
the controller records final closure.
This module owns that systemic gap:
* one canonical cross-role handoff schema (:data:`HANDOFF_FIELDS`);
* a fail-closed validator that rejects incomplete handoffs;
* live-state recovery so a receiving actor never trusts an inherited handoff;
* role-limited continuation;
* mandatory durable posting into Gitea;
* the ``merged-awaiting-controller`` boundary and controller accept/reject
continuation;
* workflow-failure escalation into separate durable issues, with duplicate
handling;
* terminal closure that must *not* emit an unnecessary next prompt.
Everything here is pure assessment: no network calls, no mutation.
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Mapping, Sequence
MARKER = "<!-- sph:v1 -->"
HANDOFF_HEADING = "Canonical Handoff"
#: Canonical workflow states a handoff may declare.
WORKFLOW_STATES: tuple[str, ...] = (
"needs-author",
"needs-review",
"approved-awaiting-merge",
"merged-awaiting-controller",
"blocked",
"complete",
)
TERMINAL_STATES = frozenset({"complete"})
#: The single role authorized to act on each workflow state.
NEXT_ACTOR_BY_STATE: dict[str, str] = {
"needs-author": "author",
"needs-review": "reviewer",
"approved-awaiting-merge": "merger",
"merged-awaiting-controller": "controller",
"blocked": "operator",
"complete": "none",
}
WORKFLOW_ROLES = frozenset(
{"author", "reviewer", "merger", "controller", "operator", "reconciler"}
)
#: What each receiving role is authorized to do when it consumes a handoff.
ROLE_ALLOWED_ACTIONS: dict[str, tuple[str, ...]] = {
"author": ("implement", "commit", "push", "create_pr", "comment"),
"reviewer": ("review", "approve", "request_changes", "comment"),
"merger": ("verify_approval_parity", "merge", "comment"),
"controller": ("accept", "reject", "reopen", "close_issue", "comment"),
"operator": ("repair_infrastructure", "comment"),
"reconciler": ("close_superseded_pr", "cleanup_branch", "comment"),
}
ROLE_FORBIDDEN_ACTIONS: dict[str, tuple[str, ...]] = {
"author": ("approve", "request_changes", "merge", "close_issue"),
"reviewer": ("merge", "commit", "push", "create_pr"),
"merger": ("approve", "commit", "push", "create_pr"),
"controller": ("approve", "merge", "commit", "push"),
"operator": ("approve", "merge", "close_issue"),
"reconciler": ("approve", "merge", "commit", "push", "create_pr"),
}
#: Ordered canonical handoff fields. Every one of them is required; the
#: fields in :data:`NONE_ALLOWED_FIELDS` may legitimately carry ``none``.
HANDOFF_FIELDS: tuple[str, ...] = (
"REPOSITORY",
"ISSUE",
"PR",
"WORKFLOW_STATE",
"HEAD_SHA",
"BASE_BRANCH",
"BASE_OR_MERGE_SHA",
"ACTING_ROLE",
"ACTING_IDENTITY",
"COMPLETED_ACTIONS",
"VALIDATION_EVIDENCE",
"MUTATION_LEDGER",
"BLOCKERS",
"NEXT_ACTOR",
"NEXT_ACTION",
"PROHIBITED_ACTIONS",
"NEXT_PROMPT",
"WORKFLOW_FAILURE_ISSUES",
"LAST_UPDATED",
)
NONE_ALLOWED_FIELDS = frozenset(
{
"PR",
"HEAD_SHA",
"BASE_OR_MERGE_SHA",
"BLOCKERS",
"WORKFLOW_FAILURE_ISSUES",
"NEXT_PROMPT",
"NEXT_ACTION",
}
)
#: States where no PR or head SHA exists yet, so ``none`` is legitimate.
_PRE_PR_STATES = frozenset({"needs-author", "blocked"})
_PLACEHOLDERS = frozenset({"", "none", "n/a", "na", "tbd", "todo", "unknown", "?"})
#: A next prompt short enough to be a stub cannot be "ready to run".
MIN_NEXT_PROMPT_CHARS = 40
_FIELD_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", re.MULTILINE)
_HEADING_RE = re.compile(r"^##\s*Canonical Handoff\s*$", re.IGNORECASE | re.MULTILINE)
_EXTERNAL_CHAT_RE = re.compile(
r"\b(?:previous chat|prior conversation|earlier conversation|see (?:the )?chat|"
r"chat history|paste (?:this )?(?:from|into) chatgpt|ask the operator to paste)\b",
re.IGNORECASE,
)
LIVE_DETECTION_KINDS: tuple[str, ...] = (
"changed_pr_head",
"stale_approval",
"issue_closed",
"issue_reopened",
"pr_merged",
"pr_closed_unmerged",
"stale_lease",
"foreign_lease",
"missing_worktree",
"dirty_worktree",
"namespace_mismatch",
"stale_runtime",
"changed_base",
"conflicting_canonical_comments",
)
CONTROLLER_DECISIONS = frozenset(
{
"accept",
"request_tests",
"request_proof",
"request_corrections",
"reopen",
"return_to_actor",
}
)
CONTROLLER_CLOSURE_PROOF_FIELDS = (
"acceptance_criteria_satisfied",
"cleanup_complete",
"canonical_final_state_posted",
"issue_closed_through_workflow",
)
WORKFLOW_FAILURE_FIELDS = (
"classification",
"linked_issue",
"temporary_impact",
"next_valid_actor",
"recovery_prompt",
)
def _is_placeholder(value: Any) -> bool:
return str(value or "").strip().lower() in _PLACEHOLDERS
def _clean(value: Any) -> str:
text = str(value).strip() if value is not None else ""
return text or "none"
# ---------------------------------------------------------------------------
# rendering / parsing
# ---------------------------------------------------------------------------
def render_self_propagating_handoff(**values: Any) -> str:
"""Render a canonical cross-role handoff block.
Raises ``ValueError`` for an unknown workflow state so a malformed handoff
can never be produced by the sanctioned renderer.
"""
state = str(values.get("WORKFLOW_STATE", values.get("workflow_state", ""))).strip()
if state not in WORKFLOW_STATES:
raise ValueError(
f"unknown workflow state '{state}'; expected one of {list(WORKFLOW_STATES)}"
)
lines = [MARKER, f"## {HANDOFF_HEADING}", "", "```text"]
for name in HANDOFF_FIELDS:
raw = values.get(name, values.get(name.lower()))
lines.append(f"{name}: {_clean(raw)}")
lines.append("```")
return "\n".join(lines)
def parse_self_propagating_handoff(text: str) -> dict[str, str] | None:
"""Parse a canonical handoff block, or ``None`` when absent."""
body = text or ""
if MARKER not in body and not _HEADING_RE.search(body):
return None
fields = {
match.group(1): match.group(2).strip()
for match in _FIELD_LINE_RE.finditer(body)
}
if not fields:
return None
return fields
def handoff_present(text: str) -> bool:
"""Whether *text* carries a canonical handoff block at all."""
return parse_self_propagating_handoff(text) is not None
# ---------------------------------------------------------------------------
# handoff validation (AC: a validator rejects incomplete handoffs)
# ---------------------------------------------------------------------------
def assess_self_propagating_handoff(text: str) -> dict[str, Any]:
"""Fail closed unless *text* carries one complete canonical handoff."""
fields = parse_self_propagating_handoff(text)
if fields is None:
return {
"valid": False,
"block": True,
"present": False,
"fields": {},
"missing_fields": list(HANDOFF_FIELDS),
"workflow_state": None,
"next_actor": None,
"terminal": False,
"reasons": ["report or comment carries no canonical handoff block"],
"safe_next_action": (
"add a canonical handoff block with all "
f"{len(HANDOFF_FIELDS)} fields before posting"
),
}
reasons: list[str] = []
state = (fields.get("WORKFLOW_STATE") or "").strip()
terminal = state in TERMINAL_STATES
if state not in WORKFLOW_STATES:
reasons.append(
f"unknown WORKFLOW_STATE '{state or 'missing'}'; "
f"expected one of {list(WORKFLOW_STATES)}"
)
missing = [name for name in HANDOFF_FIELDS if name not in fields]
reasons.extend(f"handoff missing field: {name}" for name in missing)
for name in HANDOFF_FIELDS:
if name in missing:
continue
value = fields.get(name, "")
if not _is_placeholder(value):
continue
if name in NONE_ALLOWED_FIELDS:
continue
# A terminated chain names no next actor by design.
if name == "NEXT_ACTOR" and terminal:
continue
reasons.append(f"handoff field {name} must be concrete, got '{value or ''}'")
if state and state not in _PRE_PR_STATES and state in WORKFLOW_STATES:
for name in ("PR", "HEAD_SHA"):
if name not in missing and _is_placeholder(fields.get(name)):
reasons.append(
f"handoff field {name} must be concrete in state '{state}'"
)
if state == "blocked" and _is_placeholder(fields.get("BLOCKERS")):
reasons.append("state 'blocked' requires a concrete BLOCKERS entry")
declared_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
expected_actor = NEXT_ACTOR_BY_STATE.get(state)
if expected_actor and declared_actor != expected_actor:
reasons.append(
f"NEXT_ACTOR '{declared_actor or 'missing'}' does not match state "
f"'{state}', which authorizes '{expected_actor}'"
)
next_prompt = (fields.get("NEXT_PROMPT") or "").strip()
next_action = (fields.get("NEXT_ACTION") or "").strip()
if terminal:
# A completed workflow terminates; it must not manufacture more work.
if not _is_placeholder(next_prompt):
reasons.append(
"terminal state 'complete' must not carry a NEXT_PROMPT; "
"the chain ends at controller closure"
)
if not _is_placeholder(next_action):
reasons.append(
"terminal state 'complete' must not carry a NEXT_ACTION"
)
else:
if _is_placeholder(next_prompt):
reasons.append(
"non-terminal handoff requires a complete ready-to-run NEXT_PROMPT"
)
elif len(next_prompt) < MIN_NEXT_PROMPT_CHARS:
reasons.append(
"NEXT_PROMPT is too short to be ready-to-run "
f"({len(next_prompt)} < {MIN_NEXT_PROMPT_CHARS} characters)"
)
if _is_placeholder(next_action):
reasons.append("non-terminal handoff requires a concrete NEXT_ACTION")
acting_role = (fields.get("ACTING_ROLE") or "").strip().lower()
if acting_role and acting_role not in WORKFLOW_ROLES:
reasons.append(
f"unknown ACTING_ROLE '{acting_role}'; expected one of "
f"{sorted(WORKFLOW_ROLES)}"
)
block = bool(reasons)
return {
"valid": not block,
"block": block,
"present": True,
"fields": fields,
"missing_fields": missing,
"workflow_state": state or None,
"next_actor": declared_actor or None,
"terminal": terminal,
"reasons": reasons,
"safe_next_action": (
"complete every canonical handoff field before posting"
if block
else "proceed"
),
}
def assess_thread_recoverability(text: str) -> dict[str, Any]:
"""The next actor must recover from the thread alone — never outside chat."""
assessment = assess_self_propagating_handoff(text)
if assessment["block"]:
return {
"recoverable": False,
"block": True,
"reasons": assessment["reasons"],
"safe_next_action": assessment["safe_next_action"],
}
fields = assessment["fields"]
reasons: list[str] = []
if assessment["terminal"]:
return {
"recoverable": True,
"block": False,
"reasons": [],
"safe_next_action": "proceed",
}
prompt = fields.get("NEXT_PROMPT", "")
repository = fields.get("REPOSITORY", "").strip()
issue = fields.get("ISSUE", "").strip().lstrip("#")
if repository and repository.lower() not in prompt.lower():
reasons.append("NEXT_PROMPT must name the repository it applies to")
if issue and issue not in prompt:
reasons.append(f"NEXT_PROMPT must name issue {issue}")
if _EXTERNAL_CHAT_RE.search(prompt):
reasons.append(
"NEXT_PROMPT must not depend on outside chat history; the issue or "
"PR thread, workflow docs, and live repository state must suffice"
)
block = bool(reasons)
return {
"recoverable": not block,
"block": block,
"reasons": reasons,
"safe_next_action": (
"rewrite NEXT_PROMPT so it is self-contained" if block else "proceed"
),
}
# ---------------------------------------------------------------------------
# live-state recovery (AC: head changes invalidate stale review/merge handoffs)
# ---------------------------------------------------------------------------
def _detection(kind: str, detail: str) -> dict[str, str]:
return {"kind": kind, "detail": detail}
def assess_handoff_live_state(
*,
handoff: str | Mapping[str, str],
live: Mapping[str, Any],
) -> dict[str, Any]:
"""Re-derive workflow truth from live state instead of trusting *handoff*.
*live* carries observed facts; absent keys are simply not checked, but any
fact that contradicts the inherited handoff fails closed.
"""
if isinstance(handoff, Mapping):
fields = dict(handoff)
else:
parsed = parse_self_propagating_handoff(handoff or "")
if parsed is None:
return {
"block": True,
"detections": [_detection("missing_handoff", "no canonical handoff")],
"kinds": ["missing_handoff"],
"reasons": ["no canonical handoff to reconcile against live state"],
"recovered_state": None,
"safe_next_action": "post a canonical handoff before continuing",
}
fields = parsed
state = (fields.get("WORKFLOW_STATE") or "").strip()
next_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
detections: list[dict[str, str]] = []
recovered_state: str | None = None
handoff_head = (fields.get("HEAD_SHA") or "").strip()
live_head = str(live.get("pr_head_sha") or "").strip()
head_changed = bool(
live_head and handoff_head and not _is_placeholder(handoff_head)
and live_head != handoff_head
)
if head_changed:
detections.append(
_detection(
"changed_pr_head",
f"handoff pinned {handoff_head}, live head is {live_head}",
)
)
if next_actor in {"reviewer", "merger"}:
recovered_state = "needs-review"
approved_head = str(live.get("approved_head_sha") or "").strip()
if approved_head and live_head and approved_head != live_head:
detections.append(
_detection(
"stale_approval",
f"approval recorded at {approved_head}, live head is {live_head}",
)
)
if next_actor == "merger":
recovered_state = "needs-review"
issue_state = str(live.get("issue_state") or "").strip().lower()
if issue_state == "closed" and state not in TERMINAL_STATES:
detections.append(
_detection("issue_closed", "linked issue is closed but handoff is not complete")
)
if issue_state == "open" and state in TERMINAL_STATES:
detections.append(
_detection("issue_reopened", "handoff claims complete but the issue is open")
)
recovered_state = "needs-author"
pr_state = str(live.get("pr_state") or "").strip().lower()
if pr_state == "merged" and state in {
"needs-author",
"needs-review",
"approved-awaiting-merge",
}:
detections.append(
_detection("pr_merged", "PR is already merged; controller boundary applies")
)
recovered_state = "merged-awaiting-controller"
if pr_state == "closed" and state not in TERMINAL_STATES:
detections.append(
_detection("pr_closed_unmerged", "PR is closed without merge")
)
lease = live.get("lease") or {}
if isinstance(lease, Mapping) and lease:
lease_status = str(lease.get("status") or "").strip().lower()
if lease_status and lease_status != "active":
detections.append(
_detection("stale_lease", f"lease status is '{lease_status}'")
)
lease_session = str(lease.get("session_id") or "").strip()
actor_session = str(live.get("actor_session_id") or "").strip()
if lease_session and actor_session and lease_session != actor_session:
detections.append(
_detection(
"foreign_lease",
"lease is owned by another session; never adopt it implicitly",
)
)
worktree = live.get("worktree") or {}
if isinstance(worktree, Mapping) and worktree:
if worktree.get("present") is False:
detections.append(_detection("missing_worktree", "bound worktree is absent"))
if worktree.get("dirty") is True:
detections.append(
_detection("dirty_worktree", "bound worktree carries uncommitted changes")
)
namespace_role = str(live.get("namespace_role") or "").strip().lower()
if namespace_role and next_actor and next_actor != "none":
if namespace_role != next_actor:
detections.append(
_detection(
"namespace_mismatch",
f"live namespace role '{namespace_role}' cannot act as '{next_actor}'",
)
)
if live.get("runtime_stale") is True:
detections.append(
_detection("stale_runtime", "serving runtime is stale; reconnect required")
)
handoff_base = (fields.get("BASE_BRANCH") or "").strip()
live_base = str(live.get("base_branch") or "").strip()
if handoff_base and live_base and not _is_placeholder(handoff_base):
if handoff_base != live_base:
detections.append(
_detection(
"changed_base",
f"handoff base '{handoff_base}' but live base '{live_base}'",
)
)
if live.get("conflicting_canonical_comments") is True:
detections.append(
_detection(
"conflicting_canonical_comments",
"thread carries contradictory canonical comments",
)
)
kinds = [item["kind"] for item in detections]
reasons = [f"{item['kind']}: {item['detail']}" for item in detections]
block = bool(detections)
return {
"block": block,
"detections": detections,
"kinds": kinds,
"reasons": reasons,
"recovered_state": recovered_state,
"safe_next_action": (
"post a corrected canonical handoff for the recovered live state "
"before acting"
if block
else "proceed"
),
}
# ---------------------------------------------------------------------------
# role-limited continuation
# ---------------------------------------------------------------------------
def assess_role_continuation(
*,
handoff: str | Mapping[str, str],
actor_role: str,
) -> dict[str, Any]:
"""Only the role the current state authorizes may continue the chain."""
if isinstance(handoff, Mapping):
fields = dict(handoff)
else:
fields = parse_self_propagating_handoff(handoff or "") or {}
role = (actor_role or "").strip().lower()
state = (fields.get("WORKFLOW_STATE") or "").strip()
expected = NEXT_ACTOR_BY_STATE.get(state)
reasons: list[str] = []
if not fields:
reasons.append("no canonical handoff to continue from")
if role not in WORKFLOW_ROLES:
reasons.append(f"unknown actor role '{actor_role}'")
if expected is None and fields:
reasons.append(f"unknown workflow state '{state}'")
elif expected == "none":
reasons.append(
"workflow state 'complete' is terminal; no further role may continue"
)
elif expected and role != expected:
reasons.append(
f"state '{state}' authorizes '{expected}', not '{role}'"
)
block = bool(reasons)
return {
"allowed": not block,
"block": block,
"expected_actor": expected,
"actor_role": role,
"allowed_actions": () if block else ROLE_ALLOWED_ACTIONS.get(role, ()),
"forbidden_actions": ROLE_FORBIDDEN_ACTIONS.get(role, ()),
"reasons": reasons,
"safe_next_action": (
f"hand off to '{expected}'" if block and expected else
"stop; the workflow is complete" if expected == "none" else
"proceed"
),
}
# ---------------------------------------------------------------------------
# durable posting (AC: a chat-only report is never sufficient)
# ---------------------------------------------------------------------------
def assess_durable_state_update(
*,
handoff_text: str,
posted_comment_id: Any = None,
canonical_state_posted: bool = False,
) -> dict[str, Any]:
"""A successful actor session must leave the handoff in Gitea, not chat."""
reasons: list[str] = []
assessment = assess_self_propagating_handoff(handoff_text)
if assessment["block"]:
reasons.extend(assessment["reasons"])
if not posted_comment_id:
reasons.append(
"canonical handoff was not posted to Gitea; a chat-only report is "
"not durable workflow state"
)
if not canonical_state_posted:
reasons.append(
"canonical issue/PR state and thread ledger were not updated"
)
block = bool(reasons)
return {
"durable": not block,
"block": block,
"posted_comment_id": posted_comment_id,
"reasons": reasons,
"safe_next_action": (
"post the canonical handoff and state update to Gitea before "
"ending the session"
if block
else "proceed"
),
}
# ---------------------------------------------------------------------------
# merge -> controller boundary and controller continuation
# ---------------------------------------------------------------------------
def assess_merge_completion_transition(
*,
merge_succeeded: bool,
controller_auto_accept: bool = False,
) -> dict[str, Any]:
"""A merged PR is not accepted work until the controller says so."""
if not merge_succeeded:
return {
"next_state": "approved-awaiting-merge",
"next_actor": "merger",
"next_prompt_required": True,
"reasons": ["merge did not succeed; the merger retains the work item"],
}
if controller_auto_accept:
return {
"next_state": "complete",
"next_actor": "none",
"next_prompt_required": False,
"reasons": ["configured workflow authorizes automatic acceptance on merge"],
}
return {
"next_state": "merged-awaiting-controller",
"next_actor": "controller",
"next_prompt_required": True,
"reasons": [
"merge succeeded; acceptance requires the authorized controller"
],
}
def assess_controller_decision(
*,
decision: str,
closure_proof: Mapping[str, Any] | None = None,
return_to: str | None = None,
) -> dict[str, Any]:
"""Controller acceptance or rejection produces the next or final state."""
normalized = (decision or "").strip().lower()
if normalized not in CONTROLLER_DECISIONS:
return {
"block": True,
"next_state": None,
"next_actor": None,
"next_prompt_required": True,
"reasons": [
f"unknown controller decision '{decision}'; expected one of "
f"{sorted(CONTROLLER_DECISIONS)}"
],
"safe_next_action": "record a supported controller decision",
}
if normalized == "accept":
proof = dict(closure_proof or {})
missing = [
name
for name in CONTROLLER_CLOSURE_PROOF_FIELDS
if proof.get(name) is not True
]
if missing:
return {
"block": True,
"next_state": "merged-awaiting-controller",
"next_actor": "controller",
"next_prompt_required": True,
"reasons": [
"controller acceptance missing closure proof: " + ", ".join(missing)
],
"safe_next_action": (
"satisfy and record every closure proof field before closing"
),
}
return {
"block": False,
"next_state": "complete",
"next_actor": "none",
"next_prompt_required": False,
"reasons": ["controller accepted; workflow chain terminates"],
"safe_next_action": "post the final canonical state and stop",
}
if normalized == "return_to_actor":
target = (return_to or "").strip().lower()
state_by_actor = {
"author": "needs-author",
"reviewer": "needs-review",
"merger": "approved-awaiting-merge",
}
if target not in state_by_actor:
return {
"block": True,
"next_state": None,
"next_actor": None,
"next_prompt_required": True,
"reasons": [
f"return_to_actor requires a target in {sorted(state_by_actor)}"
],
"safe_next_action": "name the actor the work returns to",
}
return {
"block": False,
"next_state": state_by_actor[target],
"next_actor": target,
"next_prompt_required": True,
"reasons": [f"controller returned the work item to '{target}'"],
"safe_next_action": f"post a complete handoff for '{target}'",
}
# request_tests / request_proof / request_corrections / reopen
return {
"block": False,
"next_state": "needs-author",
"next_actor": "author",
"next_prompt_required": True,
"reasons": [f"controller decision '{normalized}' returns the work to the author"],
"safe_next_action": "post a complete author handoff describing what is required",
}
# ---------------------------------------------------------------------------
# workflow-failure escalation
# ---------------------------------------------------------------------------
def assess_workflow_failure_escalation(
*,
failures: Sequence[Mapping[str, Any]] | None,
active_issue_number: int | str | None,
existing_failure_issues: Iterable[Mapping[str, Any]] | None = None,
) -> dict[str, Any]:
"""Tooling defects hit while working an issue become separate durable work."""
entries = list(failures or [])
known = {
str((item.get("signature") or "")).strip().lower(): item.get("number")
for item in (existing_failure_issues or [])
if str((item.get("signature") or "")).strip()
}
active = str(active_issue_number or "").strip().lstrip("#")
reasons: list[str] = []
reused: list[dict[str, Any]] = []
seen_signatures: dict[str, str] = {}
for index, failure in enumerate(entries):
label = str(failure.get("signature") or f"failure[{index}]")
missing = [
name
for name in WORKFLOW_FAILURE_FIELDS
if _is_placeholder(failure.get(name))
]
if missing:
reasons.append(
f"{label}: workflow failure missing " + ", ".join(missing)
)
linked = str(failure.get("linked_issue") or "").strip().lstrip("#")
if linked and active and linked == active:
reasons.append(
f"{label}: workflow defects must not be folded into the active "
f"work item #{active}; file a separate durable issue"
)
signature = str(failure.get("signature") or "").strip().lower()
if not signature:
continue
if signature in known:
expected = str(known[signature] or "").strip().lstrip("#")
if linked and expected and linked != expected:
reasons.append(
f"{label}: duplicate workflow-failure issue #{linked}; "
f"reuse the existing issue #{expected}"
)
else:
reused.append({"signature": signature, "issue": expected})
if signature in seen_signatures:
reasons.append(
f"{label}: duplicate workflow-failure signature reported twice "
"in one session"
)
else:
seen_signatures[signature] = linked
block = bool(reasons)
return {
"escalated": not block,
"block": block,
"failure_count": len(entries),
"reused_issues": reused,
"reasons": reasons,
"safe_next_action": (
"file or reference one durable issue per distinct workflow failure"
if block
else "proceed"
),
}
# ---------------------------------------------------------------------------
# final-report integration
# ---------------------------------------------------------------------------
def assess_final_report_self_propagating_handoff(report_text: str) -> dict[str, Any]:
"""#626 gate for final reports.
Applicability mirrors the #495 canonical-state gate: once a report adopts
the protocol by carrying the marker, the ``Canonical Handoff`` heading,
or a ``WORKFLOW_STATE`` line the full schema is enforced. Reports that
predate the protocol are untouched here; the workflow schemas require the
block going forward.
"""
text = report_text or ""
applicable = (
MARKER in text
or bool(_HEADING_RE.search(text))
or bool(re.search(r"^WORKFLOW_STATE\s*:", text, re.MULTILINE))
)
if not applicable:
return {
"applicable": False,
"valid": True,
"block": False,
"reasons": [],
"safe_next_action": "proceed",
}
assessment = assess_self_propagating_handoff(text)
recoverability = assess_thread_recoverability(text)
reasons = list(assessment["reasons"])
if not assessment["block"]:
reasons.extend(recoverability.get("reasons") or [])
block = bool(assessment["block"] or recoverability.get("block"))
return {
"applicable": True,
"valid": not block,
"block": block,
"workflow_state": assessment.get("workflow_state"),
"next_actor": assessment.get("next_actor"),
"terminal": assessment.get("terminal"),
"reasons": reasons,
"safe_next_action": (
assessment["safe_next_action"]
if assessment["block"]
else recoverability.get("safe_next_action", "proceed")
),
}
+718
View File
@@ -0,0 +1,718 @@
"""Sentry → Gitea incident bridge (#607).
Reads unresolved issues/events from a **self-hosted** Sentry, normalizes them
into #612 observations, and reconciles them into durable Gitea issues.
Hard rules (inherited from #612 and restated here):
* Gitea owns workflow state; Sentry is observability **input only**.
* Raw Sentry incidents are never assignable control-plane ``work_items``.
* Dedupe/link/create is delegated to :mod:`incident_bridge` this module
never invents a second linking substrate.
* Tokens, DSNs, and raw headers never appear in returns, bodies, or logs.
* The watchdog defaults to dry-run; ``apply`` is explicit.
Network access is injected as ``http_fn`` so the whole surface is testable
without a live Sentry.
"""
from __future__ import annotations
import dataclasses
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Callable, Sequence
import incident_bridge
import sentry_observability
PROVIDER = "sentry"
ENV_BASE_URL = "SENTRY_BASE_URL"
ENV_AUTH_TOKEN = "SENTRY_AUTH_TOKEN"
ENV_ORG = "SENTRY_ORG"
ENV_PROJECT = "SENTRY_PROJECT"
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
ENV_BRIDGE_ENABLED = "MCP_SENTRY_ISSUE_BRIDGE_ENABLED"
ENV_MIN_EVENTS = "MCP_SENTRY_MIN_EVENTS_FOR_ISSUE"
ENV_LOOKBACK = "MCP_SENTRY_LOOKBACK"
DEFAULT_BASE_URL = "https://sentry.prgs.cc"
DEFAULT_LOOKBACK = "24h"
DEFAULT_MIN_EVENTS = 2
DEFAULT_TIMEOUT = 15.0
DEFAULT_PAGE_SIZE = 25
MAX_PAGE_SIZE = 100
DEFAULT_MAX_PAGES = 10
_TRUTHY = frozenset({"1", "true", "yes", "on"})
# Absolute local paths embedded in free text. Mirrors the shape matched by
# sentry_observability's internal path detector; each hit is replaced by the
# coarse category from sentry_observability.sanitize_path.
_ABS_PATH_RE = re.compile(
r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*"
)
_LOOKBACK_RE = re.compile(r"^\d+[mhd]$")
_CURSOR_RE = re.compile(r'cursor="([^"]+)"')
_RESULTS_RE = re.compile(r'results="([^"]+)"')
_REL_RE = re.compile(r'rel="([^"]+)"')
# Error kinds surfaced to callers (stable strings; safe to branch on).
ERROR_NOT_CONFIGURED = "not_configured"
ERROR_MISSING_TOKEN = "missing_token"
ERROR_UNAVAILABLE = "sentry_unavailable"
ERROR_HTTP = "sentry_http_error"
ERROR_INVALID_RESPONSE = "invalid_response"
ERROR_BRIDGE_DISABLED = "bridge_disabled"
# Watchdog per-issue dispositions.
ACTION_RECONCILED = "reconciled"
ACTION_SKIPPED_THRESHOLD = "skipped_below_event_threshold"
ACTION_SKIPPED_STATUS = "skipped_not_unresolved"
ACTION_FAILED = "failed"
class SentryApiError(RuntimeError):
"""Sentry read failure with a stable, redacted classification."""
def __init__(self, message: str, *, kind: str, status: int | None = None):
super().__init__(incident_bridge.redact_text(message))
self.kind = kind
self.status = status
def as_dict(self) -> dict[str, Any]:
return {
"error_kind": self.kind,
"status": self.status,
"message": str(self),
}
@dataclass(frozen=True)
class SentryBridgeConfig:
"""Resolved bridge configuration. Never carries the auth token."""
base_url: str
org: str
project: str
environment: str | None = None
lookback: str = DEFAULT_LOOKBACK
min_events_for_issue: int = DEFAULT_MIN_EVENTS
bridge_enabled: bool = False
timeout: float = DEFAULT_TIMEOUT
def issues_path(self) -> str:
return f"/api/0/projects/{self.org}/{self.project}/issues/"
def issue_events_path(self, issue_id: str) -> str:
return f"/api/0/issues/{issue_id}/events/"
def as_dict(self) -> dict[str, Any]:
"""Safe projection. The auth token is never included by construction."""
return {
"base_url": self.base_url,
"org": self.org,
"project": self.project,
"environment": self.environment,
"lookback": self.lookback,
"min_events_for_issue": self.min_events_for_issue,
"bridge_enabled": self.bridge_enabled,
"self_hosted": not self.base_url.rstrip("/").endswith("sentry.io"),
}
def _env_bool(name: str, env: dict[str, str], default: bool = False) -> bool:
raw = (env.get(name) or "").strip().lower()
if not raw:
return default
return raw in _TRUTHY
def _env_int(name: str, env: dict[str, str], default: int) -> int:
raw = (env.get(name) or "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
return default
return value if value >= 1 else default
def load_bridge_config(env: dict[str, str] | None = None) -> SentryBridgeConfig:
"""Build config from environment. Never reads or returns the token value."""
source = dict(env if env is not None else os.environ)
base_url = (source.get(ENV_BASE_URL) or DEFAULT_BASE_URL).strip().rstrip("/")
lookback = (source.get(ENV_LOOKBACK) or DEFAULT_LOOKBACK).strip()
if not _LOOKBACK_RE.match(lookback):
lookback = DEFAULT_LOOKBACK
environment = (source.get(ENV_ENVIRONMENT) or "").strip() or None
return SentryBridgeConfig(
base_url=base_url,
org=(source.get(ENV_ORG) or "").strip(),
project=(source.get(ENV_PROJECT) or "").strip(),
environment=environment,
lookback=lookback,
min_events_for_issue=_env_int(ENV_MIN_EVENTS, source, DEFAULT_MIN_EVENTS),
bridge_enabled=_env_bool(ENV_BRIDGE_ENABLED, source, False),
)
def config_with_overrides(
config: SentryBridgeConfig,
*,
base_url: str | None = None,
org: str | None = None,
project: str | None = None,
lookback: str | None = None,
min_events_for_issue: int | None = None,
) -> SentryBridgeConfig:
"""Return *config* with explicit per-call overrides applied."""
overrides: dict[str, Any] = {}
if base_url:
overrides["base_url"] = str(base_url).strip().rstrip("/")
if org:
overrides["org"] = str(org).strip()
if project:
overrides["project"] = str(project).strip()
if lookback:
candidate = str(lookback).strip()
overrides["lookback"] = candidate if _LOOKBACK_RE.match(candidate) else config.lookback
if min_events_for_issue is not None:
overrides["min_events_for_issue"] = max(1, int(min_events_for_issue))
return dataclasses.replace(config, **overrides) if overrides else config
def resolve_token(env: dict[str, str] | None = None) -> str:
"""Return the Sentry auth token from env only (never logged or returned)."""
source = env if env is not None else os.environ
return (source.get(ENV_AUTH_TOKEN) or "").strip()
def assert_configured(config: SentryBridgeConfig, token: str) -> None:
"""Fail closed before any network call."""
missing = [
name
for name, value in (
(ENV_BASE_URL, config.base_url),
(ENV_ORG, config.org),
(ENV_PROJECT, config.project),
)
if not value
]
if missing:
raise SentryApiError(
"Sentry bridge is not configured; missing " + ", ".join(sorted(missing)),
kind=ERROR_NOT_CONFIGURED,
)
if not token:
raise SentryApiError(
f"{ENV_AUTH_TOKEN} is not set; refusing to call Sentry (fail closed)",
kind=ERROR_MISSING_TOKEN,
)
# --------------------------------------------------------------------------
# HTTP layer (injectable)
# --------------------------------------------------------------------------
# http_fn(url, headers, timeout) -> (status, body_bytes, response_headers)
HttpFn = Callable[[str, dict[str, str], float], "tuple[int, bytes, dict[str, str]]"]
def _default_http_fn(
url: str, headers: dict[str, str], timeout: float
) -> tuple[int, bytes, dict[str, str]]:
request = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return (
int(response.status),
response.read(),
{k.lower(): v for k, v in response.headers.items()},
)
except urllib.error.HTTPError as exc: # status is meaningful
try:
body = exc.read()
except Exception: # noqa: BLE001 - body is best-effort only
body = b""
return (
int(exc.code),
body,
{k.lower(): v for k, v in (exc.headers or {}).items()},
)
except urllib.error.URLError as exc:
raise SentryApiError(
f"Sentry unreachable: {exc.reason}", kind=ERROR_UNAVAILABLE
) from exc
except TimeoutError as exc:
raise SentryApiError("Sentry request timed out", kind=ERROR_UNAVAILABLE) from exc
def parse_next_cursor(link_header: str | None) -> str | None:
"""Extract the ``rel="next"`` cursor when more results exist."""
if not link_header:
return None
for part in link_header.split(","):
rel = _REL_RE.search(part)
if not rel or rel.group(1) != "next":
continue
results = _RESULTS_RE.search(part)
if results and results.group(1).lower() != "true":
return None
cursor = _CURSOR_RE.search(part)
if cursor:
return cursor.group(1)
return None
def _get_json(
config: SentryBridgeConfig,
path: str,
params: dict[str, Any],
*,
token: str,
http_fn: HttpFn | None = None,
) -> tuple[Any, dict[str, str]]:
caller = http_fn or _default_http_fn
query = urllib.parse.urlencode(
{k: v for k, v in params.items() if v not in (None, "")}
)
url = f"{config.base_url}{path}"
if query:
url = f"{url}?{query}"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "gitea-tools-sentry-bridge/1.0",
}
status, body, response_headers = caller(url, headers, config.timeout)
if status in (401, 403):
raise SentryApiError(
"Sentry rejected the auth token (unauthorized)",
kind=ERROR_MISSING_TOKEN,
status=status,
)
if status >= 500:
raise SentryApiError(
f"Sentry server error (HTTP {status})",
kind=ERROR_UNAVAILABLE,
status=status,
)
if status >= 400:
raise SentryApiError(
f"Sentry request failed (HTTP {status})", kind=ERROR_HTTP, status=status
)
try:
payload = json.loads(body.decode("utf-8") or "null")
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SentryApiError(
f"Sentry returned an unparseable response: {exc}",
kind=ERROR_INVALID_RESPONSE,
status=status,
) from exc
return payload, response_headers
# --------------------------------------------------------------------------
# Sanitization
# --------------------------------------------------------------------------
def _clean(value: Any) -> str:
"""Redact secrets, then replace embedded local paths with a category token.
``sentry_observability.sanitize_path`` categorizes a string that *is* a
path; it must never be applied to whole free-text fields (it would collapse
a title or timestamp to ``"other"``). Here it is applied only to substrings
that actually match an absolute path.
"""
text = incident_bridge.redact_text(value)
if not text:
return ""
return _ABS_PATH_RE.sub(
lambda m: f"[path:{sentry_observability.sanitize_path(m.group(0))}]", text
)
def sanitize_issue(raw: Any) -> dict[str, Any]:
"""Project one raw Sentry issue into a sanitized, LLM-safe summary."""
if not isinstance(raw, dict):
raise SentryApiError(
"Sentry issue payload is not an object", kind=ERROR_INVALID_RESPONSE
)
issue_id = raw.get("id")
if issue_id is None or str(issue_id).strip() == "":
raise SentryApiError(
"Sentry issue payload is missing 'id'", kind=ERROR_INVALID_RESPONSE
)
metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {}
try:
count = int(raw.get("count"))
except (TypeError, ValueError):
count = None
permalink = _clean(raw.get("permalink"))
if "[REDACTED]" in permalink:
permalink = ""
user_count = raw.get("userCount")
return {
"id": str(issue_id).strip(),
"short_id": _clean(raw.get("shortId")) or None,
"title": _clean(raw.get("title"))[:200],
"culprit": _clean(raw.get("culprit")) or None,
"level": _clean(raw.get("level")) or None,
"status": str(raw.get("status") or "unresolved").strip().lower() or "unresolved",
"count": count,
"user_count": user_count if isinstance(user_count, int) else None,
"first_seen": _clean(raw.get("firstSeen")) or None,
"last_seen": _clean(raw.get("lastSeen")) or None,
"permalink": permalink or None,
"metadata_value": _clean(metadata.get("value"))[:500] or None,
"metadata_type": _clean(metadata.get("type")) or None,
}
def sanitize_event(raw: Any) -> dict[str, Any]:
"""Project one raw Sentry event into a sanitized summary."""
if not isinstance(raw, dict):
raise SentryApiError(
"Sentry event payload is not an object", kind=ERROR_INVALID_RESPONSE
)
tags: dict[str, str] = {}
raw_tags = raw.get("tags")
if isinstance(raw_tags, list):
# Sentry events return tags as [{"key": ..., "value": ...}, ...]
tags = incident_bridge.sanitize_tags(
{
t.get("key"): t.get("value")
for t in raw_tags
if isinstance(t, dict) and t.get("key")
}
)
elif isinstance(raw_tags, dict):
tags = incident_bridge.sanitize_tags(raw_tags)
return {
"event_id": _clean(raw.get("eventID") or raw.get("id")) or None,
"message": _clean(raw.get("message") or raw.get("title"))[:2000] or None,
"date_created": _clean(raw.get("dateCreated")) or None,
"platform": _clean(raw.get("platform")) or None,
"environment": _clean(raw.get("environment")) or None,
"release": _clean(raw.get("release")) or None,
"tags": tags,
}
# --------------------------------------------------------------------------
# Reads
# --------------------------------------------------------------------------
def list_issues(
config: SentryBridgeConfig,
*,
token: str,
query: str = "is:unresolved",
limit: int = DEFAULT_PAGE_SIZE,
max_pages: int = DEFAULT_MAX_PAGES,
cursor: str | None = None,
environment: str | None = None,
http_fn: HttpFn | None = None,
) -> dict[str, Any]:
"""List sanitized unresolved Sentry issues, following ``Link`` pagination."""
assert_configured(config, token)
page_size = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
pages_allowed = max(1, int(max_pages or 1))
issues: list[dict[str, Any]] = []
next_cursor = cursor
pages_fetched = 0
for _ in range(pages_allowed):
payload, headers = _get_json(
config,
config.issues_path(),
{
"query": query,
"statsPeriod": config.lookback,
"limit": page_size,
"cursor": next_cursor,
"environment": environment or config.environment,
},
token=token,
http_fn=http_fn,
)
pages_fetched += 1
if payload is None:
payload = []
if not isinstance(payload, list):
raise SentryApiError(
"Sentry issue list response was not a JSON array",
kind=ERROR_INVALID_RESPONSE,
)
issues.extend(sanitize_issue(item) for item in payload)
next_cursor = parse_next_cursor(headers.get("link"))
if not next_cursor:
break
return {
"success": True,
"issues": issues,
"count": len(issues),
"pages_fetched": pages_fetched,
"next_cursor": next_cursor,
"inventory_complete": next_cursor is None,
"config": config.as_dict(),
"query": query,
}
def get_issue_events(
config: SentryBridgeConfig,
issue_id: str,
*,
token: str,
limit: int = 10,
http_fn: HttpFn | None = None,
) -> dict[str, Any]:
"""Fetch sanitized recent events plus the latest event for one issue."""
assert_configured(config, token)
if not str(issue_id or "").strip():
raise SentryApiError("issue_id is required", kind=ERROR_INVALID_RESPONSE)
issue_key = str(issue_id).strip()
payload, _ = _get_json(
config,
config.issue_events_path(issue_key),
{"limit": max(1, min(int(limit or 10), MAX_PAGE_SIZE))},
token=token,
http_fn=http_fn,
)
if payload is None:
payload = []
if not isinstance(payload, list):
raise SentryApiError(
"Sentry event list response was not a JSON array",
kind=ERROR_INVALID_RESPONSE,
)
events = [sanitize_event(item) for item in payload]
return {
"success": True,
"issue_id": issue_key,
"events": events,
"count": len(events),
"latest_event": events[0] if events else None,
"config": config.as_dict(),
}
# --------------------------------------------------------------------------
# Observation mapping + policy
# --------------------------------------------------------------------------
def observation_from_issue(
issue: dict[str, Any],
config: SentryBridgeConfig,
*,
gitea_org: str | None = None,
gitea_repo: str | None = None,
latest_event: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Convert a sanitized Sentry issue into a #612 observation dict."""
tags = dict(latest_event.get("tags") or {}) if isinstance(latest_event, dict) else {}
environment = None
if isinstance(latest_event, dict):
environment = latest_event.get("environment")
environment = environment or config.environment
observation: dict[str, Any] = {
"provider": PROVIDER,
"provider_base_url": config.base_url,
"provider_org": config.org,
"provider_project": config.project,
"provider_issue_id": issue.get("id"),
"provider_short_id": issue.get("short_id"),
"provider_permalink": issue.get("permalink"),
"title": issue.get("title"),
"culprit": issue.get("culprit"),
"summary": issue.get("metadata_value") or issue.get("title"),
"level": issue.get("level"),
"status": issue.get("status") or "unresolved",
"event_count": issue.get("count"),
"first_seen": issue.get("first_seen"),
"last_seen": issue.get("last_seen"),
"environment": environment,
"tags": tags,
}
if gitea_org:
observation["gitea_org"] = gitea_org
if gitea_repo:
observation["gitea_repo"] = gitea_repo
return observation
def should_bridge_issue(
issue: dict[str, Any], config: SentryBridgeConfig
) -> tuple[bool, str]:
"""Policy gate: is this Sentry issue worth a durable Gitea issue?"""
status = str(issue.get("status") or "").strip().lower()
if status and status != "unresolved":
return False, f"status '{status}' is not unresolved"
count = issue.get("count")
threshold = int(config.min_events_for_issue or 1)
if isinstance(count, int) and count < threshold:
return (
False,
f"event count {count} below {ENV_MIN_EVENTS} threshold {threshold}",
)
return True, "meets bridge policy"
# --------------------------------------------------------------------------
# Watchdog
# --------------------------------------------------------------------------
def watchdog(
db: Any,
config: SentryBridgeConfig,
*,
token: str,
apply: bool = False,
mappings: Sequence[Any] | None = None,
gitea_org: str | None = None,
gitea_repo: str | None = None,
query: str = "is:unresolved",
limit: int = DEFAULT_PAGE_SIZE,
max_pages: int = DEFAULT_MAX_PAGES,
http_fn: HttpFn | None = None,
create_issue_fn: Any = None,
comment_issue_fn: Any = None,
reconcile_fn: Callable[..., dict[str, Any]] | None = None,
fetch_events: bool = True,
) -> dict[str, Any]:
"""Scan Sentry and reconcile active incidents into Gitea issues.
Dry-run by default. ``apply=True`` additionally requires the bridge to be
explicitly enabled via ``MCP_SENTRY_ISSUE_BRIDGE_ENABLED``.
``comment_issue_fn`` carries the sanctioned issue-comment route used for
AC4 recurrence comments on already-linked issues; dry runs never comment.
"""
result: dict[str, Any] = {
"success": False,
"apply": bool(apply),
"scanned": 0,
"reconciled": 0,
"skipped": 0,
"failed": 0,
"results": [],
"reasons": [],
"config": config.as_dict(),
"raw_incident_assignable": False,
"durable_work_system": "gitea_issues",
}
if apply and not config.bridge_enabled:
result["reasons"].append(
f"{ENV_BRIDGE_ENABLED} is not enabled; apply refused (fail closed)"
)
result["error_kind"] = ERROR_BRIDGE_DISABLED
return result
try:
listing = list_issues(
config,
token=token,
query=query,
limit=limit,
max_pages=max_pages,
http_fn=http_fn,
)
except SentryApiError as exc:
result["reasons"].append(str(exc))
result.update(exc.as_dict())
return result
reconciler = reconcile_fn or incident_bridge.reconcile_incident
result["inventory_complete"] = listing.get("inventory_complete", False)
result["pages_fetched"] = listing.get("pages_fetched", 0)
for issue in listing.get("issues", []):
result["scanned"] += 1
eligible, reason = should_bridge_issue(issue, config)
if not eligible:
result["skipped"] += 1
result["results"].append(
{
"sentry_issue_id": issue.get("id"),
"action": (
ACTION_SKIPPED_THRESHOLD
if "threshold" in reason
else ACTION_SKIPPED_STATUS
),
"reason": reason,
}
)
continue
latest_event = None
if fetch_events:
try:
events = get_issue_events(
config, issue["id"], token=token, limit=1, http_fn=http_fn
)
latest_event = events.get("latest_event")
except SentryApiError as exc:
# Event enrichment is best-effort; the issue itself still bridges.
result["reasons"].append(
f"event fetch failed for {issue.get('id')}: {exc}"
)
observation = observation_from_issue(
issue,
config,
gitea_org=gitea_org,
gitea_repo=gitea_repo,
latest_event=latest_event,
)
try:
reconciled = reconciler(
db,
observation=observation,
mappings=list(mappings or []),
apply=bool(apply),
create_issue_fn=create_issue_fn,
comment_issue_fn=comment_issue_fn,
)
except Exception as exc: # noqa: BLE001 - one bad issue must not abort the scan
result["failed"] += 1
result["results"].append(
{
"sentry_issue_id": issue.get("id"),
"action": ACTION_FAILED,
"reason": incident_bridge.redact_text(exc),
}
)
continue
result["reconciled"] += 1
result["results"].append(
{
"sentry_issue_id": issue.get("id"),
"action": ACTION_RECONCILED,
"outcome": reconciled.get("outcome"),
"gitea_issue": reconciled.get("gitea_issue"),
"existing_link": reconciled.get("existing_link"),
"recurrence_comment": reconciled.get("recurrence_comment"),
"reasons": reconciled.get("reasons"),
}
)
result["success"] = result["failed"] == 0
if not result["results"]:
result["reasons"].append("no Sentry issues matched the scan window/policy")
return result
+8
View File
@@ -35,3 +35,11 @@ Install for Codex:
```
Preflight via MCP: `mcp_check_workflow_skill_preflight`.
## Tool inventory
Which tools actually exist is documented in
[`docs/mcp-tool-inventory.md`](../../docs/mcp-tool-inventory.md), which a test
holds equal to the registered set. Never plan a mutation against a tool that is
not listed there — that is the #781 failure mode, where a documented
`gitea_edit_issue` did not exist until execution time.
+56
View File
@@ -168,6 +168,42 @@ Tooling: call `gitea_record_stable_branch_push_attempt` to classify/record a
proposed push before running it; `gitea_audit_stable_branch_contamination` to
inspect or (reconciler-only) clear the marker.
## Runtime Recovery Protection (#630)
MCP connectivity is recovered through **sanctioned reconnect/restart only**:
host auto-reconnect, an explicit client reconnect, an IDE/client relaunch, or an
operator-owned restart. Worker sessions must never kill the daemons their own
proof depends on.
**Forbidden for author/reviewer/merger sessions:**
- `pkill -f mcp_server.py`, `pkill -f gitea_mcp_server`, broad `pkill -f mcp`.
- `killall` of a daemon, or `kill <pid>` of an MCP daemon pid.
- Any pattern broad enough to sweep unrelated namespaces (`pkill -f python`),
even when it never names MCP.
**Allowed (never blocked):** read-only inspection (`ps aux | grep mcp_server`),
and process management unrelated to the daemons — a `kill` of some other pid is
reported as *ambiguous*, not as contamination.
**What happens on a detected attempt:** the session is marked
workflow-contaminated (durable marker, redacted command summary + session id +
remote + role). While contaminated, all review / merge / close / completion
mutations fail closed. `comment_issue` and `lock_issue` remain allowed so the
contaminated worker can post the durable audit comment and hand off.
Contamination **cannot be self-cleared** — only a reconciler audit may clear it,
and it does not expire with the session-state TTL. The final report must surface
the contaminated recovery and must not claim a clean session.
Operator-authorized host maintenance stays permitted, but the authorization is
read from the operator's environment, never from a tool argument: a session must
not be able to authorize itself.
Tooling: call `gitea_record_daemon_process_kill_attempt` to classify/record a
proposed command before running it; `gitea_audit_runtime_recovery_contamination`
to inspect or (reconciler-only) clear the marker. Full contrast in
`docs/mcp-namespace-eof-recovery.md`.
## Shell Spawn Hard-Stop Rule
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
@@ -216,6 +252,15 @@ Helpers: `scripts/worktree-start`, `scripts/worktree-review`,
- Never place raw tokens in LLM/MCP config.
- Use `gitea_whoami` and `gitea_resolve_task_capability` before mutating.
## Tool inventory
[`docs/mcp-tool-inventory.md`](../../docs/mcp-tool-inventory.md) is the canonical
list of registered tools, held equal to the live registry by a test. A tool that
is not listed there does not exist — do not scope work around it (#781).
Issue content is edited with `gitea_edit_issue` (title/body only, read-after-write
verified). `gitea_edit_pr` is pull-request-only and never accepts an issue number.
## Controller Handoff
Every task must end with a section titled exactly `Controller Handoff`. Compact
@@ -224,6 +269,17 @@ format canonical field set per issue #182; mode-specific schemas in
for the loaded workflow mode — not the legacy compact block alone.
`review_proofs.assess_controller_handoff()` validates presence.
## Canonical self-propagating handoff
Every workflow mode also carries the cross-role handoff block defined in
[`schemas/self-propagating-handoff.md`](schemas/self-propagating-handoff.md)
(#626). Each actor consumes exactly one canonical handoff, performs exactly one
authorized role, posts the result to the Gitea issue or PR thread, and emits the
next complete handoff — until the controller records final closure. The block
must be posted to Gitea, not returned in chat alone, and the next prompt is not
an optional prose section. `self_propagating_handoff.py` implements the schema;
`final_report_validator.py` enforces it as `shared.self_propagating_handoff`.
## Prompt templates
Ready-to-copy task prompts live in [`templates/`](templates/):
@@ -44,3 +44,7 @@ mutations occurred).
```
Identity format: `username / profile` (not personal email unless required — #305).
The report must also carry the canonical self-propagating handoff block
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
the Gitea issue thread.
@@ -29,3 +29,7 @@ use `none` where nothing occurred. Validated by
* Read-only diagnostics:
* Blockers:
* Safe next action: (fresh run for the next PR)
The report must also carry the canonical self-propagating handoff block
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
the Gitea PR thread.
@@ -39,6 +39,7 @@ occurred).
- Git ref mutations:
- MCP/Gitea mutations:
- Reconciliation mutations:
- Terminal label cleanup:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
@@ -50,4 +51,15 @@ occurred).
Identity format: `username / profile` (not personal email unless required — #305).
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
`Terminal label cleanup` (#780) reports the `pr_open_label_cleanup` record the
reconciliation tool returned — `clean` / `failed` / `not applicable (no linked
issue)`, with the labels removed and preserved. Reconciliation is a terminal
transition, so a non-`clean` record blocks any "reconciled" claim; recover with
`gitea_cleanup_terminal_pr_labels` (`terminal_reason='retry_recovery'`) and
confirm with `gitea_assess_terminal_label_hygiene`.
The report must also carry the canonical self-propagating handoff block
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
the Gitea issue or PR thread.
@@ -99,6 +99,21 @@ Narrative final report and controller handoff must agree on eligibility class,
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
terminal review mutation, merge result, and linked issue status.
### Terminal label state (#780)
A run that takes a PR to a terminal state — merged, closed without merge,
superseded, or reconciled as already landed — must report what happened to the
linked issue's `status:pr-open` label, quoting the `pr_open_label_cleanup`
record the terminal tool returned:
- Terminal label cleanup: `clean` / `failed` / `not applicable (no linked issue)`
- Labels removed and preserved per issue, with the read-after-write read-back
Never claim the transition is complete while that record is not `clean`. A
failed cleanup does not undo the merge; the safe next action is
`gitea_cleanup_terminal_pr_labels` with `terminal_reason='retry_recovery'`,
confirmed by `gitea_assess_terminal_label_hygiene`.
### Proof-backed claims (#395)
Proof-sensitive claims must cite explicit command/tool evidence in the report
@@ -116,4 +131,9 @@ or structured MCP metadata — not narrative alone:
When a claim relies on prior-session blocker state or MCP metadata only, label
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
`not checked`). Do not use `live proof` without that classification.
`not checked`). Do not use `live proof` without that classification.
The report must also carry the canonical self-propagating handoff block
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
the Gitea PR thread. A reviewer hands off to `merger`; a merger transitions to
`merged-awaiting-controller` rather than declaring the work accepted.
@@ -0,0 +1,114 @@
# Canonical self-propagating handoff schema (#626)
**Applies to:** every workflow actor — author, reviewer, merger, controller,
operator, reconciler.
`#494``#507` defined the ledger, the canonical state comments, and the
Canonical Thread Handoff shape. This schema owns the *chain*: each actor
consumes exactly one canonical handoff, performs exactly one authorized role,
records the result durably in Gitea, and emits the next complete handoff —
until the controller records final closure.
Implemented and enforced by `self_propagating_handoff.py`; wired into
`final_report_validator.py` as rule `shared.self_propagating_handoff`.
## The block
Post this block into the Gitea issue or PR thread, and include it verbatim in
the final report. It is not an optional prose section.
```md
<!-- sph:v1 -->
## Canonical Handoff
```text
REPOSITORY: <org>/<repo>
ISSUE: <number>
PR: <number or none>
WORKFLOW_STATE: <one of the workflow states below>
HEAD_SHA: <current head, or none before a branch exists>
BASE_BRANCH: <base branch>
BASE_OR_MERGE_SHA: <base SHA, or merge commit SHA after merge>
ACTING_ROLE: <author|reviewer|merger|controller|operator|reconciler>
ACTING_IDENTITY: <username (profile)>
COMPLETED_ACTIONS: <what this actor actually did>
VALIDATION_EVIDENCE: <commands run and their results>
MUTATION_LEDGER: <every durable mutation performed>
BLOCKERS: <active blockers, or none>
NEXT_ACTOR: <role authorized by WORKFLOW_STATE, or none when complete>
NEXT_ACTION: <exact next action, or none when complete>
PROHIBITED_ACTIONS: <what the next actor must not do>
NEXT_PROMPT: <complete ready-to-run prompt, or none when complete>
WORKFLOW_FAILURE_ISSUES: <durable issue refs for tooling defects, or none>
LAST_UPDATED: <UTC timestamp>
```
```
## Workflow states and the single authorized actor
| `WORKFLOW_STATE` | `NEXT_ACTOR` |
| --------------------------- | ------------ |
| `needs-author` | `author` |
| `needs-review` | `reviewer` |
| `approved-awaiting-merge` | `merger` |
| `merged-awaiting-controller`| `controller` |
| `blocked` | `operator` |
| `complete` | `none` |
A merged PR is **not** accepted work: merge transitions to
`merged-awaiting-controller` unless the configured workflow explicitly
authorizes automatic acceptance.
## Fail-closed rules
* Every field is required. Only `PR`, `HEAD_SHA`, `BASE_OR_MERGE_SHA`,
`BLOCKERS`, `WORKFLOW_FAILURE_ISSUES`, `NEXT_ACTION`, and `NEXT_PROMPT` may
carry `none`, and `PR`/`HEAD_SHA` only in `needs-author` or `blocked`.
* `NEXT_ACTOR` must equal the actor the declared state authorizes.
* `blocked` requires a concrete `BLOCKERS` entry.
* A non-terminal handoff requires a concrete `NEXT_ACTION` and a
`NEXT_PROMPT` long enough to be ready to run.
* `complete` must carry no `NEXT_ACTION` and no `NEXT_PROMPT`: a finished
workflow terminates instead of manufacturing more work.
* `NEXT_PROMPT` must name the repository and the issue, and must not depend on
outside chat history. The issue or PR thread, workflow documentation, and
live repository state must be sufficient to recover the task.
* The handoff must be posted to Gitea. A chat-only report is not durable
workflow state.
## Live-state recovery before acting
The receiving actor re-derives truth from live state instead of trusting the
inherited handoff. `assess_handoff_live_state` detects and fails closed on:
`changed_pr_head`, `stale_approval`, `issue_closed`, `issue_reopened`,
`pr_merged`, `pr_closed_unmerged`, `stale_lease`, `foreign_lease`,
`missing_worktree`, `dirty_worktree`, `namespace_mismatch`, `stale_runtime`,
`changed_base`, and `conflicting_canonical_comments`.
A changed head invalidates any inherited review or merge handoff; the chain
recovers to `needs-review`.
## Controller closure
`accept` is only honored with all four closure proofs recorded:
`acceptance_criteria_satisfied`, `cleanup_complete`,
`canonical_final_state_posted`, `issue_closed_through_workflow`. Otherwise the
work item stays at `merged-awaiting-controller`.
`request_tests`, `request_proof`, `request_corrections`, and `reopen` return
the work to the author; `return_to_actor` returns it to a named earlier actor.
## Workflow-failure escalation
Tooling or workflow defects found while working an item never get folded into
the active feature issue. Each distinct failure carries `classification`,
`linked_issue`, `temporary_impact`, `next_valid_actor`, and `recovery_prompt`.
A failure whose signature already has a durable issue must reuse that issue
instead of filing a duplicate.
## Applicability
Enforcement is applicability-gated exactly like the #495 canonical-state gate:
once a report carries the `sph:v1` marker, the `Canonical Handoff` heading, or
a `WORKFLOW_STATE:` line, the full schema is enforced and incomplete handoffs
are rejected. Reports written before the protocol existed are unaffected.
@@ -70,4 +70,9 @@ selected issue, and mutation ledger categories (#319, #320).
`Read-only diagnostics` (#297).
Forbidden claims without proof (#330): `next eligible issue`, `issue claimed`,
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
The report must also carry the canonical self-propagating handoff block
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
the Gitea issue or PR thread. The next prompt is not an optional prose
section.
@@ -450,6 +450,35 @@ If any gate fails, do not create the issue.
Produce a recovery handoff or duplicate report.
### 18a. Sanctioned first-mutation path (#749)
`gitea_create_issue` is a **pure remote mutation** (no local tree write). The
issue-first gate forbids creating `branches/issue-<N>-*` before the issue
number exists. Therefore the **only sanctioned first mutation** is:
1. Read-only identity + capability + duplicate search from the control checkout.
2. Ensure the **canonical control checkout** is:
* the configured repository root for the requested remote/org/repo;
* on an accepted base branch (`master` / `main` / `dev`);
* base-equivalent to live master;
* clean (no tracked local edits);
* in runtime/master parity.
3. Resolve exact task `create_issue`, then call `gitea_create_issue` **from that
clean control checkout** (no `worktree_path` required for this step alone).
4. After the issue number exists: create a **registered** worktree under
`branches/issue-<N>-*`, claim/lock, and perform every subsequent author
mutation from that worktree only.
**Forbidden improvisations (fail closed):**
* `mkdir` dummy directories under `branches/` (#713)
* borrowing an unrelated pre-existing worktree
* creating a pre-issue worktree in violation of issue-first
* running create_issue from a dirty, drifted, detached, or non-canonical root
Post-creation mutations (`lock_issue`, commit, push, `create_pr`, etc.) **never**
receive this bootstrap exemption.
## 19. Issue commenting gate
Before commenting on an existing issue, verify:
+641
View File
@@ -0,0 +1,641 @@
"""Stable-control vs dev/test runtime classification and mutation gates (#615).
``docs/architecture/mcp-stable-control-runtime-policy-adr.md`` states the policy:
real Gitea mutations may only be performed by the **stable control runtime**,
while MCP server development happens in isolated ``branches/`` worktrees and
optional dev/test runtimes. The ADR alone is not enforcement a daemon
relaunched from a feature worktree still holds production credentials and will
happily mutate production issues.
This module supplies the runtime half of that policy:
* :func:`classify_runtime_mode` decides whether the running process is a
``stable-control``, ``dev-test``, or ``unknown`` runtime.
* :func:`build_runtime_report` collects the reporting fields the ADR requires
(mode, SHA, branch, checkout path, process root, workspace, binding, dirty
files, alignment, and whether real mutations are allowed).
* :func:`assess_runtime_mutation_gate` turns that report into a fail-closed
mutation gate.
* The post-transport-flap helpers keep namespace re-proving **per namespace**,
so proving the author namespace never implies the reviewer, merger, or
reconciler namespace is callable.
Every assessment is pure: callers inject the observed facts, so the logic is
unit-testable without a git checkout or a live daemon. Only the thin
:func:`observe_runtime` reader touches the filesystem.
"""
from __future__ import annotations
import os
import subprocess
# Operator declaration of the runtime this process is. An explicit, valid
# declaration wins over inference — an operator running a packaged release
# layout may have no git checkout to infer from.
ENV_RUNTIME_MODE = "GITEA_MCP_RUNTIME_MODE"
# Escape hatch mirroring the #420 parity gate: disables enforcement only.
ENV_DISABLE = "GITEA_MCP_DISABLE_RUNTIME_MODE_GATE"
RUNTIME_MODE_STABLE = "stable-control"
RUNTIME_MODE_DEV_TEST = "dev-test"
RUNTIME_MODE_UNKNOWN = "unknown"
VALID_RUNTIME_MODES = frozenset(
{RUNTIME_MODE_STABLE, RUNTIME_MODE_DEV_TEST, RUNTIME_MODE_UNKNOWN}
)
# Branches a stable control checkout is allowed to sit on. Anything else is a
# development checkout by definition (the global worktree rule keeps the
# control checkout on a stable branch).
STABLE_BRANCHES = frozenset({"master", "main", "dev"})
# Path segment that marks an isolated development worktree.
DEV_WORKTREE_SEGMENT = "branches"
BLOCKER_DEV_TEST_PRODUCTION = "dev_test_runtime_targets_production"
BLOCKER_UNKNOWN_RUNTIME = "unknown_runtime_mode"
BLOCKER_DIRTY_STABLE_RUNTIME = "dirty_stable_runtime_checkout"
BLOCKER_DEV_WORKTREE_LAUNCH = "runtime_launched_from_dev_worktree"
BLOCKER_UNSAFE_ALIGNMENT = "unsafe_process_root_workspace_alignment"
BLOCKER_NAMESPACE_NOT_REPROVEN = "namespace_not_reproven_after_flap"
# Namespaces that must each be re-proven independently after a transport flap.
WORKFLOW_NAMESPACES = ("author", "reviewer", "merger", "reconciler")
def gate_disabled() -> bool:
"""Whether the runtime-mode gate is disabled by env escape hatch."""
return bool((os.environ.get(ENV_DISABLE) or "").strip())
def declared_runtime_mode() -> str | None:
"""Return the operator-declared runtime mode, if a valid one is set.
An unset or unrecognised value returns ``None`` so classification falls
back to inference rather than trusting a typo.
"""
value = (os.environ.get(ENV_RUNTIME_MODE) or "").strip().lower()
if value in VALID_RUNTIME_MODES:
return value
return None
def _path_segments(path: str) -> list[str]:
return [seg for seg in os.path.normpath(path).split(os.sep) if seg]
def launched_from_dev_worktree(process_root: str | None) -> bool:
"""Whether *process_root* sits inside a ``branches/`` development worktree."""
if not process_root:
return False
return DEV_WORKTREE_SEGMENT in _path_segments(process_root)
def classify_runtime_mode(
*,
process_root: str | None,
checkout_branch: str | None,
is_git_checkout: bool = True,
declared_mode: str | None = None,
) -> dict:
"""Classify the runtime this process is serving from.
``declared_mode`` (normally :func:`declared_runtime_mode`) is authoritative
when supplied and valid. Otherwise the mode is inferred:
* no resolvable root, or a root that is not a git checkout ``unknown``
(a packaged deployment must declare its mode explicitly);
* a root inside a ``branches/`` worktree ``dev-test``;
* an unreadable branch ``unknown``;
* a stable branch (``master``/``main``/``dev``) ``stable-control``;
* any other branch ``dev-test``.
Returns the mode plus the discriminating facts and human-readable reasons.
"""
reasons: list[str] = []
dev_worktree = launched_from_dev_worktree(process_root)
if declared_mode in VALID_RUNTIME_MODES:
reasons.append(
f"runtime mode declared by operator via {ENV_RUNTIME_MODE}="
f"{declared_mode}"
)
return _mode_result(declared_mode, dev_worktree, True, reasons)
if not process_root:
reasons.append(
"runtime process root could not be resolved; runtime mode is "
"indeterminate"
)
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
if not is_git_checkout:
reasons.append(
f"runtime process root '{process_root}' is not a git checkout and "
f"no {ENV_RUNTIME_MODE} declaration was supplied"
)
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
if dev_worktree:
reasons.append(
f"runtime was launched from development worktree '{process_root}' "
f"(inside '{DEV_WORKTREE_SEGMENT}/')"
)
return _mode_result(RUNTIME_MODE_DEV_TEST, True, False, reasons)
branch = (checkout_branch or "").strip()
if not branch:
reasons.append(
f"runtime checkout branch at '{process_root}' could not be read "
f"(detached HEAD or unreadable); runtime mode is indeterminate"
)
return _mode_result(RUNTIME_MODE_UNKNOWN, False, False, reasons)
if branch in STABLE_BRANCHES:
reasons.append(
f"runtime checkout '{process_root}' is on stable branch '{branch}'"
)
return _mode_result(RUNTIME_MODE_STABLE, False, False, reasons)
reasons.append(
f"runtime checkout '{process_root}' is on development branch "
f"'{branch}', not a stable branch "
f"({', '.join(sorted(STABLE_BRANCHES))})"
)
return _mode_result(RUNTIME_MODE_DEV_TEST, False, False, reasons)
def _mode_result(mode, dev_worktree, declared, reasons) -> dict:
return {
"runtime_mode": mode,
"dev_worktree_launched": bool(dev_worktree),
"declared": bool(declared),
"reasons": list(reasons),
}
def build_runtime_report(
*,
process_root: str | None,
checkout_branch: str | None,
runtime_head: str | None,
active_task_workspace: str | None = None,
canonical_repository_root: str | None = None,
repository_slug: str | None = None,
profile: str | None = None,
authenticated_identity: str | None = None,
dirty_files: list[str] | tuple[str, ...] | None = None,
workspace_roots_aligned: bool | None = None,
is_git_checkout: bool = True,
declared_mode: str | None = None,
) -> dict:
"""Build the ADR-required runtime report (#615 acceptance criterion 6).
``real_mutations_allowed`` is the summary bit: it is true only when the
corresponding mutation gate finds nothing to block on for a production
target.
"""
classification = classify_runtime_mode(
process_root=process_root,
checkout_branch=checkout_branch,
is_git_checkout=is_git_checkout,
declared_mode=declared_mode,
)
report = {
"runtime_mode": classification["runtime_mode"],
"runtime_mode_declared": classification["declared"],
"runtime_mode_reasons": classification["reasons"],
"dev_worktree_launched": classification["dev_worktree_launched"],
"runtime_git_sha": runtime_head,
"runtime_branch": checkout_branch,
"runtime_checkout_path": process_root,
"mcp_process_root": process_root,
"active_task_workspace": active_task_workspace,
"canonical_repository_root": canonical_repository_root,
"repository_slug": repository_slug,
"profile": profile,
"authenticated_identity": authenticated_identity,
"dirty_files": sorted(dirty_files or []),
"workspace_roots_aligned": workspace_roots_aligned,
"gate_enforced": not gate_disabled(),
}
gate = assess_runtime_mutation_gate(report)
report["real_mutations_allowed"] = not gate["block"]
report["mutation_block_reasons"] = gate["reasons"]
return report
def assess_runtime_mutation_gate(
report: dict,
*,
target_is_production: bool = True,
namespace: str | None = None,
namespace_reproof: dict | None = None,
) -> dict:
"""Fail-closed mutation gate for the runtime a mutation would execute in.
Blocks when (acceptance criterion 7):
* the runtime is ``dev-test`` and the mutation targets the production
repository;
* the runtime mode is ``unknown``;
* the stable runtime checkout is dirty;
* the runtime was launched from a development worktree;
* process-root / workspace alignment is unsafe;
* (criterion 8) the namespace has not been re-proven since a transport flap.
The disabled escape hatch never blocks; the caller decides read-vs-mutate
before calling.
"""
reasons: list[str] = []
blockers: list[str] = []
if gate_disabled():
return _gate_result(False, blockers, reasons, disabled=True)
mode = (report or {}).get("runtime_mode")
if mode == RUNTIME_MODE_UNKNOWN:
blockers.append(BLOCKER_UNKNOWN_RUNTIME)
reasons.append(
"runtime mode is 'unknown'; a runtime that cannot prove it is the "
f"stable control runtime must not mutate production (declare "
f"{ENV_RUNTIME_MODE} or run from a stable checkout)"
)
if mode == RUNTIME_MODE_DEV_TEST and target_is_production:
blockers.append(BLOCKER_DEV_TEST_PRODUCTION)
reasons.append(
"runtime mode is 'dev-test' and the mutation targets the "
"production repository; dev/test runtimes must not mutate real "
"issues or PRs (ADR: stable control runtime vs dev runtime)"
)
if report.get("dev_worktree_launched") and target_is_production:
blockers.append(BLOCKER_DEV_WORKTREE_LAUNCH)
reasons.append(
f"runtime was launched from a '{DEV_WORKTREE_SEGMENT}/' development "
f"worktree ('{report.get('mcp_process_root')}'); production "
f"mutations require the promoted stable control runtime"
)
dirty = list(report.get("dirty_files") or [])
if mode == RUNTIME_MODE_STABLE and dirty:
blockers.append(BLOCKER_DIRTY_STABLE_RUNTIME)
reasons.append(
"stable control runtime checkout is dirty "
f"({len(dirty)} file(s): {', '.join(dirty[:5])}"
f"{'...' if len(dirty) > 5 else ''}); the control plane must run "
"promoted, unmodified code"
)
if report.get("workspace_roots_aligned") is False:
blockers.append(BLOCKER_UNSAFE_ALIGNMENT)
reasons.append(
"process-root / active-workspace alignment is unsafe; the runtime "
"and the task workspace disagree about which checkout is being "
"mutated"
)
if namespace:
reproof = assess_namespace_reproof(namespace_reproof, namespace)
if reproof["reproof_required"] and not reproof["proven"]:
blockers.append(BLOCKER_NAMESPACE_NOT_REPROVEN)
reasons.extend(reproof["reasons"])
return _gate_result(bool(blockers), blockers, reasons)
def _gate_result(block, blockers, reasons, *, disabled=False) -> dict:
return {
"block": bool(block),
"blocker_kinds": list(blockers),
"blocker_kind": blockers[0] if blockers else None,
"reasons": list(reasons),
"gate_disabled": bool(disabled),
}
def runtime_block_reasons(
report: dict,
*,
target_is_production: bool = True,
namespace: str | None = None,
namespace_reproof: dict | None = None,
) -> list[str]:
"""Block reasons for a mutation gate (empty when the mutation may proceed)."""
gate = assess_runtime_mutation_gate(
report,
target_is_production=target_is_production,
namespace=namespace,
namespace_reproof=namespace_reproof,
)
return gate["reasons"]
def runtime_report_payload(report: dict, gate: dict | None = None) -> dict:
"""Structured recovery payload for permission-block responses."""
gate = gate or assess_runtime_mutation_gate(report)
return {
"kind": "runtime_mode_block",
"runtime_mode": report.get("runtime_mode"),
"runtime_git_sha": report.get("runtime_git_sha"),
"runtime_branch": report.get("runtime_branch"),
"runtime_checkout_path": report.get("runtime_checkout_path"),
"blocker_kind": gate.get("blocker_kind"),
"blocker_kinds": list(gate.get("blocker_kinds") or []),
"reasons": list(gate.get("reasons") or []),
"recovery": [
"Real workflow mutations run only on the promoted stable control "
"runtime (see docs/architecture/"
"mcp-stable-control-runtime-policy-adr.md).",
"Operator action: promote the intended revision into the stable "
"runtime and reload it — see "
"docs/stable-runtime-promotion-runbook.md.",
"Normal author/reviewer/merger/reconciler sessions must not kill, "
"restart, or relaunch the MCP server themselves.",
],
}
def format_runtime_mode(report: dict) -> str:
"""One-line human summary for logs / runtime context."""
mode = report.get("runtime_mode") or RUNTIME_MODE_UNKNOWN
sha = report.get("runtime_git_sha")
branch = report.get("runtime_branch") or "unknown-branch"
short = sha[:12] if sha else "unknown-sha"
suffix = (
"" if report.get("real_mutations_allowed", True) else " (mutations blocked)"
)
return f"{mode} at {short} on {branch}{suffix}"
# ---------------------------------------------------------------------------
# Post-transport-flap namespace re-proving (#615 acceptance criterion 8)
#
# A transport flap (#584) drops every gitea-* namespace at once. Proving the
# author namespace afterwards says nothing about the reviewer, merger, or
# reconciler namespace, so proof is tracked per namespace and a flap
# invalidates all of them.
# ---------------------------------------------------------------------------
REQUIRED_NAMESPACE_PROOF_STEPS = (
"whoami",
"runtime_context",
"capability_resolved",
)
def new_reproof_state() -> dict:
"""Return an empty post-flap re-proving state."""
return {"flap_at": None, "namespaces": {}}
def record_transport_flap(state: dict | None, *, at: str) -> dict:
"""Record a transport flap: every namespace must be re-proven after *at*.
Existing per-namespace proofs are kept for audit but no longer satisfy the
gate, because they were recorded before the flap.
"""
result = dict(state or new_reproof_state())
result["flap_at"] = at
result["namespaces"] = dict(result.get("namespaces") or {})
return result
def record_namespace_proof(
state: dict | None,
namespace: str,
*,
at: str,
whoami: bool = False,
runtime_context: bool = False,
capability_resolved: bool = False,
stale_runtime_reported: bool = False,
) -> dict:
"""Record proof steps completed for exactly one namespace.
A namespace whose proof reported a reconnect/restart/stale-runtime gate is
never counted as proven, regardless of which steps ran.
"""
result = dict(state or new_reproof_state())
namespaces = dict(result.get("namespaces") or {})
namespaces[(namespace or "").strip()] = {
"at": at,
"whoami": bool(whoami),
"runtime_context": bool(runtime_context),
"capability_resolved": bool(capability_resolved),
"stale_runtime_reported": bool(stale_runtime_reported),
}
result["namespaces"] = namespaces
return result
def assess_namespace_reproof(state: dict | None, namespace: str) -> dict:
"""Whether *namespace* is re-proven after the most recent transport flap.
``reproof_required`` is false when no flap has been recorded this gate
only speaks to post-flap proof and never invents a requirement.
"""
ns = (namespace or "").strip()
store = state or {}
flap_at = store.get("flap_at")
reasons: list[str] = []
if not flap_at:
return {
"namespace": ns,
"reproof_required": False,
"proven": True,
"flap_at": None,
"proof_at": None,
"missing_steps": [],
"reasons": reasons,
}
entry = (store.get("namespaces") or {}).get(ns)
if not entry:
reasons.append(
f"MCP namespace '{ns}' has not been re-proven since the transport "
f"flap at {flap_at}; run whoami, runtime context, and capability "
f"resolve for '{ns}' itself (proof of another namespace does not "
f"transfer)"
)
return {
"namespace": ns,
"reproof_required": True,
"proven": False,
"flap_at": flap_at,
"proof_at": None,
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
"reasons": reasons,
}
proof_at = entry.get("at")
if proof_at is not None and str(proof_at) < str(flap_at):
reasons.append(
f"MCP namespace '{ns}' proof at {proof_at} predates the transport "
f"flap at {flap_at}; re-prove the namespace before mutating"
)
return {
"namespace": ns,
"reproof_required": True,
"proven": False,
"flap_at": flap_at,
"proof_at": proof_at,
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
"reasons": reasons,
}
missing = [step for step in REQUIRED_NAMESPACE_PROOF_STEPS if not entry.get(step)]
if missing:
reasons.append(
f"MCP namespace '{ns}' post-flap proof is incomplete; missing: "
f"{', '.join(missing)}"
)
if entry.get("stale_runtime_reported"):
missing = missing or ["stale_runtime_clear"]
reasons.append(
f"MCP namespace '{ns}' reported a reconnect/restart/stale-runtime "
f"gate during re-proving; mutation stays blocked until the "
f"namespace reconnects cleanly"
)
return {
"namespace": ns,
"reproof_required": True,
"proven": not missing,
"flap_at": flap_at,
"proof_at": proof_at,
"missing_steps": list(missing),
"reasons": reasons,
}
def unproven_namespaces(
state: dict | None, namespaces=WORKFLOW_NAMESPACES
) -> list[str]:
"""Return the namespaces still requiring post-flap re-proving."""
out = []
for ns in namespaces:
assessment = assess_namespace_reproof(state, ns)
if assessment["reproof_required"] and not assessment["proven"]:
out.append(ns)
return out
# ---------------------------------------------------------------------------
# Promotion records (#615 acceptance criterion 4 / 10)
# ---------------------------------------------------------------------------
PROMOTION_REQUIRED_FIELDS = (
"previous_runtime_sha",
"promoted_runtime_sha",
"source_branch",
"source_pr",
"restart_method",
"health_check_proof",
"identity_proof",
"profile_proof",
"workspace_proof",
"mutation_capability_proof",
"rollback_instructions",
)
def assess_promotion_record(record: dict | None) -> dict:
"""Validate an operator promotion record against the ADR checklist.
A promotion that does not record both the previous and the promoted SHA is
not a promotion it is an undocumented restart.
"""
data = record or {}
missing = [
field
for field in PROMOTION_REQUIRED_FIELDS
if not str(data.get(field) or "").strip()
]
reasons = []
if missing:
reasons.append(
"promotion record is incomplete; missing: " + ", ".join(missing)
)
previous = str(data.get("previous_runtime_sha") or "").strip()
promoted = str(data.get("promoted_runtime_sha") or "").strip()
if previous and promoted and previous == promoted:
reasons.append(
"promotion record lists the same previous and promoted SHA "
f"({previous[:12]}); nothing was promoted"
)
return {
"valid": not reasons,
"missing_fields": missing,
"reasons": reasons,
}
# ---------------------------------------------------------------------------
# Filesystem observation (the only impure helper)
# ---------------------------------------------------------------------------
def _git_capture(root: str, *args: str) -> str | None:
if not root:
return None
try:
res = subprocess.run(
["git", "-C", root, *args],
capture_output=True,
text=True,
check=False,
)
except Exception:
return None
if res.returncode != 0:
return None
return (res.stdout or "").strip() or None
def observe_dirty_files(process_root: str | None) -> list[str]:
"""Read the live dirty-file list at *process_root*.
Split out from :func:`observe_runtime` because dirtiness is the one runtime
fact that legitimately changes during a process lifetime. The mutation gate
must re-read it per call rather than trust a startup snapshot, or a checkout
that goes dirty after the snapshot is never blocked again (#615).
"""
if not process_root:
return []
porcelain = _git_capture(process_root, "status", "--porcelain") or ""
return [line[3:].strip() for line in porcelain.splitlines() if line.strip()]
def observe_runtime(process_root: str | None) -> dict:
"""Read the runtime facts classification needs from *process_root*.
Returns ``checkout_branch``, ``runtime_head``, ``is_git_checkout``, and
``dirty_files``. Every read failure degrades to ``None``/empty rather than
raising, so a runtime that cannot be inspected classifies as ``unknown``
instead of crashing the caller.
"""
empty = {
"checkout_branch": None,
"runtime_head": None,
"is_git_checkout": False,
"dirty_files": [],
}
if not process_root:
return empty
if not _git_capture(process_root, "rev-parse", "--show-toplevel"):
return empty
branch = _git_capture(process_root, "rev-parse", "--abbrev-ref", "HEAD")
if branch == "HEAD": # detached HEAD has no branch name
branch = None
dirty = observe_dirty_files(process_root)
return {
"checkout_branch": branch,
"runtime_head": _git_capture(process_root, "rev-parse", "HEAD"),
"is_git_checkout": True,
"dirty_files": dirty,
}
+101
View File
@@ -36,6 +36,20 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.issue.comment",
"role": "author",
},
# #781: editing an issue title/body is issue authoring, the same authority
# every other non-create/non-close issue mutation gates on. Deliberately not
# a new operation name: introducing one would silently strip the capability
# from every already-configured author profile.
"edit_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
# #780: retire status:pr-open after a terminal PR transition. Same label
# authority as set_issue_labels — it is a strictly narrower operation.
"cleanup_terminal_pr_labels": {
"permission": "gitea.issue.comment",
"role": "author",
},
"create_label": {
"permission": "gitea.issue.comment",
"role": "author",
@@ -158,6 +172,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.comment",
"role": "reviewer",
},
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned. The
# apply path posts an append-only terminal `phase: released` lease marker
# (gitea.pr.comment), so holding the comment permission alone must not
# authorize it — author, reviewer and merger fail closed on the role gate
# even though their profiles carry gitea.pr.comment. The read-only
# `apply=false` assessment deliberately stays reachable under gitea.read
# inside the tool (the same convention as cleanup_stale_review_decision_lock
# below), so any namespace can diagnose a stuck lease; only apply requires
# this task plus the reconciler role.
"cleanup_post_merge_moot_lease": {
"permission": "gitea.pr.comment",
"role": "reconciler",
},
"gitea_cleanup_post_merge_moot_lease": {
"permission": "gitea.pr.comment",
"role": "reconciler",
},
"blind_pr_queue_review": {
"permission": "gitea.pr.review",
"role": "reviewer",
@@ -415,13 +446,83 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
},
}
# A reviewer lease is the first mutation in the canonical ``review_pr``
# workflow, so the already-resolved review capability is valid for that one
# narrower transition. Keep this directed and explicit: lease acquisition
# does not authorize a review verdict, and reviewer proof never authorizes a
# merger lease (#763).
_PREFLIGHT_TASK_TRANSITIONS = frozenset({
("review_pr", "acquire_reviewer_pr_lease"),
})
def _canonical_preflight_task(task: str | None) -> str:
"""Normalize only declared ``gitea_`` aliases for preflight comparison."""
value = (task or "").strip()
if value.startswith("gitea_") and value[6:] in TASK_CAPABILITY_MAP:
return value[6:]
return value
def preflight_task_matches(
resolved_task: str | None,
mutation_task: str | None,
) -> bool:
"""Return whether capability proof authorizes this mutation transition."""
resolved = _canonical_preflight_task(resolved_task)
mutation = _canonical_preflight_task(mutation_task)
if not resolved or not mutation:
return False
return resolved == mutation or (resolved, mutation) in _PREFLIGHT_TASK_TRANSITIONS
# Tasks for which permission alone is insufficient: the active/configured
# profile's declared role must also match the task role. This is the complete
# resolver set from master at the #723 reconstruction point, shared with
# runtime reporting so those two authorities cannot drift again.
ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
{
"acquire_reviewer_pr_lease",
"gitea_acquire_reviewer_pr_lease",
"review_pr",
"approve_pr",
"request_changes_pr",
"blind_pr_queue_review",
"pr_queue_cleanup",
"pr-queue-cleanup",
"merge_pr",
"acquire_merger_pr_lease",
"gitea_acquire_merger_pr_lease",
"adopt_merger_pr_lease",
"gitea_adopt_merger_pr_lease",
"release_merger_pr_lease",
"gitea_release_merger_pr_lease",
"create_branch",
"push_branch",
"create_pr",
"commit_files",
"gitea_commit_files",
"address_pr_change_requests",
"update_pr_branch_by_merge",
"gitea_update_pr_branch_by_merge",
"delete_branch",
"cleanup_merged_pr_branch",
"reconciliation_cleanup",
"work_issue",
"work-issue",
}
)
# Issue-mutating MCP tools and their resolver task keys.
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
"gitea_create_issue": "create_issue",
"gitea_close_issue": "close_issue",
"gitea_edit_issue": "edit_issue",
"gitea_create_issue_comment": "comment_issue",
"gitea_mark_issue": "mark_issue",
"gitea_set_issue_labels": "set_issue_labels",
"gitea_cleanup_terminal_pr_labels": "cleanup_terminal_pr_labels",
"gitea_create_label": "create_label",
"gitea_commit_files": "commit_files",
}
+308
View File
@@ -0,0 +1,308 @@
"""Authoritative terminal-transition cleanup for ``status:pr-open`` (#780).
``status:pr-open`` is applied by ``gitea_create_pr`` while a linked pull
request is open. Nothing removed it again: the workflow's terminal paths
(merge, close-without-merge, supersession, already-landed reconciliation,
controller closure) each ended without touching the label, so a repository
audit found 40 closed issues still carrying it.
This module is the single source of truth for that cleanup. Every sanctioned
terminal path plans its label mutation here rather than implementing its own
rule, so the paths cannot drift apart:
- :func:`plan_pr_open_cleanup` decides the exact resulting label set. It only
ever removes ``status:pr-open``; every other label is preserved verbatim,
including the case where the result is an empty label set.
- :func:`verify_pr_open_cleanup` is the read-after-write check. It proves the
label is gone *and* that no unrelated label was dropped or added.
- :func:`detect_residual_pr_open` is the terminal validation: given issues, it
reports any that still carry the label, so a controller closure or audit
fails loudly instead of leaving the leak behind.
The rule is idempotent by construction: an issue without the label plans no
mutation, so retries and recovery re-runs are harmless.
This module performs no I/O callers own the Gitea API calls.
"""
from __future__ import annotations
from typing import Any, Iterable, Mapping, Sequence
import issue_workflow_labels
#: The single label this module is responsible for retiring.
PR_OPEN_LABEL = "status:pr-open"
# Canonical terminal reasons — the sanctioned ways an issue can end up
# associated with a pull request that is no longer open.
MERGED = "merged"
CLOSED_WITHOUT_MERGE = "closed_without_merge"
SUPERSEDED = "superseded"
ALREADY_LANDED = "already_landed"
CONTROLLER_CLOSURE = "controller_closure"
ABANDONED = "abandoned"
RETRY_RECOVERY = "retry_recovery"
TERMINAL_REASONS: tuple[str, ...] = (
MERGED,
CLOSED_WITHOUT_MERGE,
SUPERSEDED,
ALREADY_LANDED,
CONTROLLER_CLOSURE,
ABANDONED,
RETRY_RECOVERY,
)
_REASON_ALIASES: dict[str, str] = {
"merge": MERGED,
"merged": MERGED,
"pr_merged": MERGED,
"closed": CLOSED_WITHOUT_MERGE,
"close": CLOSED_WITHOUT_MERGE,
"closed_without_merge": CLOSED_WITHOUT_MERGE,
"pr_closed": CLOSED_WITHOUT_MERGE,
"supersede": SUPERSEDED,
"superseded": SUPERSEDED,
"supersession": SUPERSEDED,
"already_landed": ALREADY_LANDED,
"reconcile_already_landed": ALREADY_LANDED,
"controller_closure": CONTROLLER_CLOSURE,
"close_issue": CONTROLLER_CLOSURE,
"abandon": ABANDONED,
"abandoned": ABANDONED,
"retry": RETRY_RECOVERY,
"recovery": RETRY_RECOVERY,
"retry_recovery": RETRY_RECOVERY,
}
#: Human-readable phrasing used in audit comments and diagnostics.
REASON_DESCRIPTIONS: dict[str, str] = {
MERGED: "the linked PR was merged",
CLOSED_WITHOUT_MERGE: "the linked PR was closed without merging",
SUPERSEDED: "the linked PR was superseded by another merged PR",
ALREADY_LANDED: "the linked PR's change was already on the target branch",
CONTROLLER_CLOSURE: "the issue reached controller closure",
ABANDONED: "the linked PR was abandoned",
RETRY_RECOVERY: "a partial terminal transition is being recovered",
}
def canonical_terminal_reason(reason: str | None) -> str:
"""Normalize a terminal reason, failing closed on anything unrecognized."""
name = (reason or "").strip()
if name in TERMINAL_REASONS:
return name
normalized = name.lower().replace("-", "_").replace(" ", "_")
try:
return _REASON_ALIASES[normalized]
except KeyError as exc:
raise ValueError(
f"unknown terminal PR reason '{reason}' (expected one of: "
+ ", ".join(TERMINAL_REASONS)
+ ")"
) from exc
def plan_pr_open_cleanup(
current_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
*,
terminal_reason: str,
) -> dict[str, Any]:
"""Plan the label set an issue must carry after a terminal PR transition.
The plan removes ``status:pr-open`` and nothing else. When the label is
absent the plan is an explicit no-op (``cleanup_required`` False), which is
what makes repeated cleanup calls harmless. When it was the only label the
resulting set is legitimately empty.
"""
reason = canonical_terminal_reason(terminal_reason)
before = issue_workflow_labels.label_names(current_labels)
after = [name for name in before if name != PR_OPEN_LABEL]
present = len(after) != len(before)
return {
"terminal_reason": reason,
"terminal_reason_description": REASON_DESCRIPTIONS[reason],
"label": PR_OPEN_LABEL,
"label_present": present,
"cleanup_required": present,
"idempotent_noop": not present,
"labels_before": before,
"labels_after": after,
"removed": [PR_OPEN_LABEL] if present else [],
"preserved": list(after),
"empty_label_set": not after,
}
def verify_pr_open_cleanup(
observed_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
*,
plan: Mapping[str, Any],
) -> dict[str, Any]:
"""Read-after-write check for a planned cleanup.
Verifies the label is gone and that the observed set matches the plan
exactly, so an unrelated label silently dropped (or re-added) by the API is
reported rather than accepted.
"""
observed = issue_workflow_labels.label_names(observed_labels)
expected = list(plan.get("labels_after") or [])
observed_set = set(observed)
expected_set = set(expected)
residual = PR_OPEN_LABEL in observed_set
unexpected_removals = sorted(expected_set - observed_set)
unexpected_additions = sorted(observed_set - expected_set - {PR_OPEN_LABEL})
reasons: list[str] = []
if residual:
reasons.append(
f"'{PR_OPEN_LABEL}' is still present after terminal cleanup"
)
if unexpected_removals:
reasons.append(
"unrelated labels were dropped by the cleanup: "
+ ", ".join(unexpected_removals)
)
if unexpected_additions:
reasons.append(
"unexpected labels appeared during the cleanup: "
+ ", ".join(unexpected_additions)
)
verified = not reasons
return {
"verified": verified,
"residual": residual,
"observed_labels": observed,
"expected_labels": expected,
"unexpected_removals": unexpected_removals,
"unexpected_additions": unexpected_additions,
"empty_label_set": not observed,
"reasons": reasons,
"safe_next_action": (
""
if verified
else (
"Re-run the terminal cleanup for this issue with "
f"terminal_reason='{RETRY_RECOVERY}' and confirm the read-back "
f"no longer reports '{PR_OPEN_LABEL}'."
)
),
}
def summarize_cleanup_results(
results: Sequence[Mapping[str, Any]],
*,
terminal_reason: str,
) -> dict[str, Any]:
"""Aggregate per-issue cleanup outcomes into one reportable record."""
reason = canonical_terminal_reason(terminal_reason)
entries = [dict(entry) for entry in results]
removed = [e.get("issue_number") for e in entries if e.get("status") == "removed"]
absent = [
e.get("issue_number") for e in entries if e.get("status") == "not present"
]
failed = [
e.get("issue_number")
for e in entries
if e.get("status") not in ("removed", "not present") or not e.get("verified")
]
reasons: list[str] = []
for entry in entries:
for text in entry.get("reasons") or []:
reasons.append(f"issue #{entry.get('issue_number')}: {text}")
clean = not failed
return {
"label": PR_OPEN_LABEL,
"terminal_reason": reason,
"clean": clean,
"checked": [e.get("issue_number") for e in entries],
"removed": removed,
"already_absent": absent,
"failed": failed,
"results": entries,
"reasons": reasons,
"safe_next_action": (
""
if clean
else (
"Terminal label cleanup did not complete for "
+ ", ".join(f"#{num}" for num in failed)
+ ". Re-run gitea_cleanup_terminal_pr_labels with "
f"terminal_reason='{RETRY_RECOVERY}' for those issues."
)
),
}
def detect_residual_pr_open(
issues: Iterable[Mapping[str, Any]],
*,
open_pr_issue_numbers: Iterable[int] = (),
) -> dict[str, Any]:
"""Terminal validation: report issues still carrying ``status:pr-open``.
An issue with a genuinely open pull request is allowed to keep the label,
so *open_pr_issue_numbers* is excluded from the residual set rather than
being reported as a leak.
"""
legitimate: set[int] = set()
for num in open_pr_issue_numbers or ():
try:
legitimate.add(int(num))
except (TypeError, ValueError):
continue
checked = 0
residual: list[dict[str, Any]] = []
exempt: list[int] = []
for issue in issues or []:
checked += 1
names = issue_workflow_labels.label_names(issue)
if PR_OPEN_LABEL not in names:
continue
try:
number = int(issue.get("number"))
except (TypeError, ValueError):
number = None
if number is not None and number in legitimate:
exempt.append(number)
continue
residual.append(
{
"number": number,
"state": issue.get("state"),
"labels": names,
}
)
clean = not residual
reasons = [
(
f"issue #{entry['number']} ({entry.get('state') or 'unknown state'}) "
f"still carries '{PR_OPEN_LABEL}' with no open PR"
)
for entry in residual
]
return {
"label": PR_OPEN_LABEL,
"clean": clean,
"checked_count": checked,
"residual_count": len(residual),
"residual_issues": residual,
"exempt_open_pr_issues": sorted(exempt),
"reasons": reasons,
"safe_next_action": (
""
if clean
else (
"Run gitea_cleanup_terminal_pr_labels with "
f"terminal_reason='{RETRY_RECOVERY}' for issues "
+ ", ".join(f"#{entry['number']}" for entry in residual)
+ " before declaring the terminal transition complete."
)
),
}
+266
View File
@@ -0,0 +1,266 @@
"""Dependency parsing/resolution and allocator completeness tests (#758).
Covers the two defects behind #758:
* Defect 1 candidate truncation before ranking, which let a result-size
parameter change the winner.
* Defect 2 dependency state inferred from body substrings, which emitted
canonical ``Depends:`` blocked issues as eligible.
No production behavior is special-cased for any issue number (#758 AC14), so
these tests use synthetic issue numbers throughout.
"""
from __future__ import annotations
import os
import tempfile
import unittest
import allocator_dependencies
from allocator_service import (
OUTCOME_PREVIEW,
SELECTION_POLICY,
WorkCandidate,
allocate_next_work,
classify_skip,
sort_candidates,
)
from control_plane_db import ControlPlaneDB
# The canonical linkage line this repository writes into issue bodies.
CANONICAL_BODY = (
"## Dependencies and linkage\n\n"
"* Parent: #900 · Depends: #901, #902 · Related: #903, #904\n"
)
class ParseDependencyRefsTest(unittest.TestCase):
def test_parses_canonical_depends_field(self) -> None:
self.assertEqual(
allocator_dependencies.parse_dependency_refs(CANONICAL_BODY),
(901, 902),
)
def test_stops_at_sibling_field_and_ignores_related(self) -> None:
""""Related:" refs must never be treated as dependencies."""
refs = allocator_dependencies.parse_dependency_refs(CANONICAL_BODY)
self.assertNotIn(903, refs)
self.assertNotIn(904, refs)
self.assertNotIn(900, refs) # Parent is not a dependency
def test_single_reference(self) -> None:
body = "* Parent: #10 · Depends: #11 · Related: #12"
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (11,))
def test_depends_on_spelling_and_and_separator(self) -> None:
body = "Depends on #21 and #22\n"
self.assertEqual(
allocator_dependencies.parse_dependency_refs(body), (21, 22)
)
def test_newline_terminates_declaration(self) -> None:
body = "Depends: #31, #32\nRelated: #33\n"
self.assertEqual(
allocator_dependencies.parse_dependency_refs(body), (31, 32)
)
def test_legacy_blocked_on_marker_still_recognized(self) -> None:
body = "This work is blocked on #41 until that lands.\n"
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (41,))
def test_dependencies_heading_alone_is_not_a_declaration(self) -> None:
""""Dependencies and linkage" must not parse as "Depends"."""
body = "## Dependencies and linkage\n\n* Related: #51\n"
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
def test_deduplicates_and_preserves_order(self) -> None:
body = "Depends: #61, #62, #61\n"
self.assertEqual(
allocator_dependencies.parse_dependency_refs(body), (61, 62)
)
def test_malformed_and_empty_inputs(self) -> None:
for body in ("", None, "Depends:", "Depends: none", "Depends: TBD\n"):
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
class ResolveDependencyStateTest(unittest.TestCase):
def test_open_dependency_is_unmet(self) -> None:
result = allocator_dependencies.resolve_dependency_state(
(901, 902), lambda n: "open", subject="issue#644"
)
self.assertTrue(result["dependency_unmet"])
self.assertEqual(result["unmet"], (901, 902))
self.assertIn("#901", result["reason"])
def test_closed_dependencies_are_met(self) -> None:
result = allocator_dependencies.resolve_dependency_state(
(901, 902), lambda n: "closed"
)
self.assertFalse(result["dependency_unmet"])
self.assertEqual(result["met"], (901, 902))
self.assertIsNone(result["reason"])
def test_mixed_open_and_closed_is_unmet(self) -> None:
states = {901: "closed", 902: "open"}
result = allocator_dependencies.resolve_dependency_state(
(901, 902), states.get
)
self.assertTrue(result["dependency_unmet"])
self.assertEqual(result["unmet"], (902,))
self.assertEqual(result["met"], (901,))
def test_unavailable_evidence_fails_closed(self) -> None:
"""AC7: unknown state must block, never pass."""
result = allocator_dependencies.resolve_dependency_state(
(901,), lambda n: None
)
self.assertTrue(result["dependency_unmet"])
self.assertEqual(result["unavailable"], (901,))
self.assertIn("fail closed", result["reason"])
def test_raising_lookup_fails_closed(self) -> None:
def boom(_n: int) -> str:
raise RuntimeError("lookup exploded")
result = allocator_dependencies.resolve_dependency_state((901,), boom)
self.assertTrue(result["dependency_unmet"])
self.assertEqual(result["unavailable"], (901,))
def test_no_refs_is_eligible(self) -> None:
result = allocator_dependencies.resolve_dependency_state((), lambda n: None)
self.assertFalse(result["dependency_unmet"])
self.assertIsNone(result["reason"])
class DependencyBlockedCandidateTest(unittest.TestCase):
"""A dependency-blocked candidate must be skipped, not selected."""
def test_classify_skip_rejects_unmet_dependency(self) -> None:
candidate = WorkCandidate(
kind="issue",
number=644,
labels=("status:ready",),
priority=20,
dependency_unmet=True,
dependency_reason="issue#644 depends on unresolved issue(s) #633",
)
reason = classify_skip(candidate, role="author", terminal_pr=None)
self.assertIsNotNone(reason)
self.assertIn("#633", reason)
class SelectionInvarianceTest(unittest.TestCase):
"""AC1/AC2/AC11: ranking sees everything; result bounds cannot move the winner."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
@staticmethod
def _ready_issue(number: int, **kw) -> WorkCandidate:
return WorkCandidate(
kind="issue",
number=number,
labels=("status:ready",),
priority=20,
title=f"issue {number}",
**kw,
)
def _preview(self, candidates):
return allocate_next_work(
self.db,
session_id="s-758",
role="author",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
candidates=candidates,
apply=False,
)
def test_more_than_fifty_candidates_lowest_number_wins(self) -> None:
"""Winner is the oldest eligible issue across a >50 inventory."""
candidates = [self._ready_issue(n) for n in range(600, 700)] # 100 items
result = self._preview(candidates)
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
self.assertEqual(result["selected"]["number"], 600)
def test_selection_is_invariant_to_candidate_ordering(self) -> None:
"""Ranking must not depend on the order the inventory arrived in."""
forward = [self._ready_issue(n) for n in range(600, 700)]
reverse = list(reversed(forward))
self.assertEqual(
self._preview(forward)["selected"]["number"],
self._preview(reverse)["selected"]["number"],
)
def test_truncating_inventory_changes_winner(self) -> None:
"""Regression guard: this is exactly what pre-ranking slicing did.
A 50-item slice of a 100-item inventory yields a different winner, so
any future reintroduction of pre-ranking truncation is detectable.
"""
full = [self._ready_issue(n) for n in range(600, 700)]
sliced = sorted(full, key=lambda c: -c.number)[:50]
self.assertNotEqual(
self._preview(full)["selected"]["number"],
self._preview(sliced)["selected"]["number"],
)
def test_blocked_first_candidate_falls_through_to_next(self) -> None:
"""AC8: a blocked winner must not end the iteration."""
blocked = self._ready_issue(
600,
dependency_unmet=True,
dependency_reason="issue#600 depends on unresolved issue(s) #599",
)
result = self._preview([blocked, self._ready_issue(601)])
self.assertEqual(result["selected"]["number"], 601)
skipped = {s["number"] for s in result["skipped"]}
self.assertIn(600, skipped)
def test_all_blocked_yields_no_safe_work(self) -> None:
candidates = [
self._ready_issue(
n, dependency_unmet=True, dependency_reason=f"issue#{n} blocked"
)
for n in range(600, 605)
]
result = self._preview(candidates)
self.assertIsNone(result["selected"])
self.assertEqual(len(result["skipped"]), 5)
def test_dry_run_and_apply_select_identically(self) -> None:
"""AC9: apply mode must not re-rank differently from preview."""
candidates = [self._ready_issue(n) for n in range(600, 700)]
preview = self._preview(candidates)
applied = allocate_next_work(
self.db,
session_id="s-758-apply",
role="author",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
candidates=candidates,
apply=True,
)
self.assertEqual(
preview["selected"]["number"], applied["selected"]["number"]
)
def test_sort_is_stable_and_documented(self) -> None:
ordered = sort_candidates(
[self._ready_issue(603), self._ready_issue(601), self._ready_issue(602)]
)
self.assertEqual([c.number for c in ordered], [601, 602, 603])
self.assertIn("never affect selection", SELECTION_POLICY)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,380 @@
"""Allocator ownership exclusion tests (#765).
One session's active lease must never blockade the author queue for a
different controller. Covers: foreign lease skipped, next unclaimed candidate
selected, own task resumable, task-local blocker quarantined, all-claimed ->
wait, same profile + different controller_instance_id -> different ownership,
and claimed candidates reported in skipped results.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from allocator_service import (
OUTCOME_OWNERSHIP_DEFECT,
OUTCOME_PREVIEW,
OUTCOME_WAIT,
OWNERSHIP_FOREIGN,
OWNERSHIP_OWN,
OWNERSHIP_UNKNOWN,
SKIP_CLAIMED_BY_OTHER_SESSION,
WorkCandidate,
allocate_next_work,
classify_claim_ownership,
resolve_controller_instance_id,
)
from control_plane_db import ControlPlaneDB
REMOTE = "prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
MINE = "ctl-mine-0001"
THEIRS = "ctl-theirs-0002"
def _issue(number: int, **kwargs) -> WorkCandidate:
base = dict(
kind="issue",
number=number,
state="open",
labels=("status:ready", "type:bug"),
title=f"issue {number}",
priority=20,
)
base.update(kwargs)
return WorkCandidate(**base)
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
return {
"lease_id": f"lease-{number}",
"session_id": session_id,
"controller_instance_id": instance,
"role": "author",
"profile": "prgs-author",
"expires_at": "2026-07-20T07:06:09Z",
"work_kind": kind,
"work_number": number,
}
class AllocatorOwnershipTestCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def _allocate(
self,
candidates,
*,
claims,
session_id="sess-mine",
instance=MINE,
apply=False,
role="author",
):
return allocate_next_work(
self.db,
session_id=session_id,
role=role,
remote=REMOTE,
org=ORG,
repo=REPO,
candidates=candidates,
apply=apply,
profile_name="prgs-author",
controller_instance_id=instance,
claims=claims,
)
class TestOwnershipClassification(AllocatorOwnershipTestCase):
def test_no_claim_returns_none(self):
self.assertIsNone(
classify_claim_ownership(
None, session_id="s", controller_instance_id=MINE
)
)
def test_same_controller_instance_is_own(self):
claim = _claim(1, session_id="other-session", instance=MINE)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=MINE
),
OWNERSHIP_OWN,
)
def test_same_profile_different_instance_is_foreign(self):
"""Shared profile must not imply shared ownership."""
claim = _claim(1, session_id="other-session", instance=THEIRS)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=MINE
),
OWNERSHIP_FOREIGN,
)
def test_exact_session_match_is_own(self):
claim = _claim(1, session_id="sess-mine", instance=None)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=None
),
OWNERSHIP_OWN,
)
def test_legacy_claim_with_neither_side_identified_is_foreign(self):
"""No identities anywhere: a different session id is simply not ours."""
claim = _claim(1, session_id="someone-else", instance=None)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=None
),
OWNERSHIP_FOREIGN,
)
def test_claim_identified_but_local_undeclared_is_unknown(self):
"""Only one side identified: not comparable, so never adopt."""
claim = _claim(1, session_id="someone-else", instance=THEIRS)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=None
),
OWNERSHIP_UNKNOWN,
)
def test_local_identified_but_claim_undeclared_is_unknown(self):
"""A legacy lease may be our own under an old session id; do not guess."""
claim = _claim(1, session_id="someone-else", instance=None)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=MINE
),
OWNERSHIP_UNKNOWN,
)
def test_resolve_controller_instance_id_reads_env(self):
self.assertEqual(
resolve_controller_instance_id({"GITEA_CONTROLLER_INSTANCE_ID": MINE}),
MINE,
)
self.assertIsNone(resolve_controller_instance_id({}))
self.assertIsNone(
resolve_controller_instance_id({"GITEA_CONTROLLER_INSTANCE_ID": " "})
)
class TestForeignLeaseDoesNotBlockade(AllocatorOwnershipTestCase):
def test_foreign_claim_skipped_and_next_issue_selected(self):
"""Skip the claimed issue, select the next unclaimed one."""
candidates = [_issue(607), _issue(615), _issue(617)]
claims = {
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
}
result = self._allocate(candidates, claims=claims)
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
self.assertEqual(result["selected"]["number"], 615)
skipped_607 = [s for s in result["skipped"] if s["number"] == 607]
self.assertEqual(len(skipped_607), 1)
self.assertEqual(
skipped_607[0]["reason_code"], SKIP_CLAIMED_BY_OTHER_SESSION
)
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, skipped_607[0]["reason"])
def test_claimed_candidate_appears_in_skipped_inventory(self):
"""Skipped reporting must reflect claimed candidates."""
candidates = [_issue(607), _issue(615)]
claims = {
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
}
result = self._allocate(candidates, claims=claims)
self.assertEqual(len(result["skipped"]), 1)
self.assertEqual(len(result["claims_excluded"]), 1)
excluded = result["claims_excluded"][0]
self.assertEqual(excluded["number"], 607)
self.assertEqual(excluded["ownership"], OWNERSHIP_FOREIGN)
self.assertEqual(excluded["owner_controller_instance_id"], THEIRS)
def test_task_local_blocker_does_not_freeze_unrelated_work(self):
"""A quarantined task must not stop the rest of the queue."""
candidates = [_issue(607), _issue(615), _issue(617)]
claims = {
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
}
first = self._allocate(candidates, claims=claims)
self.assertEqual(first["selected"]["number"], 615)
# 615 then gets claimed by yet another controller; queue still advances.
claims[("issue", 615)] = _claim(
615, session_id="sess-third", instance="ctl-third-0003"
)
second = self._allocate(candidates, claims=claims)
self.assertEqual(second["selected"]["number"], 617)
def test_multiple_controllers_get_different_issues(self):
"""Concurrent author sessions work on different issues."""
candidates = [_issue(607), _issue(615)]
claims = {
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
}
mine = self._allocate(candidates, claims=claims, instance=MINE)
theirs = self._allocate(
candidates, claims=claims, session_id="sess-theirs", instance=THEIRS
)
self.assertEqual(mine["selected"]["number"], 615)
# The other controller may still be handed its own in-progress task.
self.assertEqual(theirs["selected"]["number"], 607)
def test_unclaimed_queue_is_unaffected(self):
candidates = [_issue(607), _issue(615)]
result = self._allocate(candidates, claims={})
self.assertEqual(result["selected"]["number"], 607)
self.assertEqual(result["skipped"], [])
self.assertEqual(result["claims_excluded"], [])
class TestOwnTaskResume(AllocatorOwnershipTestCase):
def test_controller_may_resume_its_own_active_task(self):
"""Own claim stays selectable across a new session id."""
candidates = [_issue(607), _issue(615)]
claims = {
("issue", 607): _claim(607, session_id="sess-mine-old", instance=MINE)
}
result = self._allocate(
candidates, claims=claims, session_id="sess-mine-new", instance=MINE
)
self.assertEqual(result["selected"]["number"], 607)
self.assertEqual(result["claims_excluded"], [])
def test_own_claim_by_exact_session_is_selectable(self):
candidates = [_issue(607)]
claims = {("issue", 607): _claim(607, session_id="sess-mine", instance=None)}
result = self._allocate(
candidates, claims=claims, session_id="sess-mine", instance=None
)
self.assertEqual(result["selected"]["number"], 607)
class TestAllCandidatesClaimed(AllocatorOwnershipTestCase):
def test_all_claimed_returns_wait_not_a_claimed_selection(self):
"""Never hand back a claimed issue; report waiting instead."""
candidates = [_issue(607), _issue(615)]
claims = {
("issue", 607): _claim(607, session_id="sess-a", instance=THEIRS),
("issue", 615): _claim(615, session_id="sess-b", instance="ctl-c-0003"),
}
result = self._allocate(candidates, claims=claims)
self.assertIsNone(result["selected"])
self.assertEqual(result["outcome"], OUTCOME_WAIT)
self.assertEqual(len(result["claims_excluded"]), 2)
def test_unidentifiable_owner_reports_ownership_defect(self):
"""Refuse to adopt when ownership cannot be established."""
candidates = [_issue(607)]
claims = {("issue", 607): _claim(607, session_id="sess-legacy", instance=None)}
result = self._allocate(candidates, claims=claims)
self.assertIsNone(result["selected"])
self.assertEqual(result["outcome"], OUTCOME_OWNERSHIP_DEFECT)
self.assertEqual(len(result["ownership_defects"]), 1)
self.assertEqual(
result["ownership_defects"][0]["ownership"], OWNERSHIP_UNKNOWN
)
class TestClaimsFromControlPlaneDb(AllocatorOwnershipTestCase):
"""End-to-end against the real substrate, not injected claim dicts."""
def _seed_lease(self, number: int, *, session_id: str, instance: str | None):
self.db.upsert_session(
session_id=session_id,
role="author",
profile="prgs-author",
pid=4242,
controller_instance_id=instance,
)
return self.db.assign_and_lease(
session_id=session_id,
role="author",
remote=REMOTE,
org=ORG,
repo=REPO,
kind="issue",
number=number,
)
def test_controller_instance_id_persists_on_session(self):
row = self.db.upsert_session(
session_id="sess-x",
role="author",
profile="prgs-author",
pid=1,
controller_instance_id=MINE,
)
self.assertEqual(row["controller_instance_id"], MINE)
def test_heartbeat_without_instance_does_not_erase_ownership(self):
self.db.upsert_session(
session_id="sess-x",
role="author",
profile="prgs-author",
pid=1,
controller_instance_id=MINE,
)
row = self.db.upsert_session(
session_id="sess-x", role="author", profile="prgs-author", pid=1
)
self.assertEqual(row["controller_instance_id"], MINE)
def test_list_active_claims_surfaces_owner_instance(self):
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
claims = self.db.list_active_claims(remote=REMOTE, org=ORG, repo=REPO)
self.assertIn(("issue", 607), claims)
self.assertEqual(claims[("issue", 607)]["controller_instance_id"], THEIRS)
def test_live_foreign_lease_is_excluded_without_injected_claims(self):
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
result = allocate_next_work(
self.db,
session_id="sess-mine",
role="author",
remote=REMOTE,
org=ORG,
repo=REPO,
candidates=[_issue(607), _issue(615)],
apply=False,
profile_name="prgs-author",
controller_instance_id=MINE,
)
self.assertEqual(result["selected"]["number"], 615)
self.assertEqual(
result["skipped"][0]["reason_code"], SKIP_CLAIMED_BY_OTHER_SESSION
)
def test_apply_reserves_the_unclaimed_issue(self):
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
result = allocate_next_work(
self.db,
session_id="sess-mine",
role="author",
remote=REMOTE,
org=ORG,
repo=REPO,
candidates=[_issue(607), _issue(615)],
apply=True,
profile_name="prgs-author",
controller_instance_id=MINE,
)
self.assertEqual(result["outcome"], "assigned_work")
self.assertEqual(result["selected"]["number"], 615)
self.assertEqual(result["assignment"]["work_number"], 615)
if __name__ == "__main__":
unittest.main()
+227
View File
@@ -0,0 +1,227 @@
"""MCP-level allocator inventory and dependency regressions (#758).
Exercises ``_allocator_candidates_from_gitea`` and the ``gitea_allocate_next_work``
tool end to end against a faked Gitea API, proving:
* the complete open-issue inventory is ranked (no pre-ranking truncation);
* ``limit`` cannot change which candidate wins;
* canonical ``Depends:`` declarations are resolved from live issue state;
* unavailable dependency evidence fails closed;
* an incomplete listing fails closed instead of ranking a partial set.
Issue numbers here are synthetic; no production number is special-cased.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import patch
import gitea_mcp_server as srv
from control_plane_db import ControlPlaneDB
FAKE_AUTH = "token REDACTED"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
def _issue(number: int, *, body: str = "", labels=("status:ready",)) -> dict:
return {
"number": number,
"title": f"issue {number}",
"body": body,
"labels": [{"name": name} for name in labels],
"state": "open",
}
def _depends_body(*refs: int) -> str:
joined = ", ".join(f"#{r}" for r in refs)
return f"## Dependencies and linkage\n\n* Parent: #999 · Depends: {joined}\n"
class _FakeGitea:
"""Minimal stand-in for the two Gitea list endpoints plus issue lookups."""
def __init__(self, issues, *, closed=(), unavailable=(), fail_issue_list=False):
self.issues = list(issues)
self.closed = set(closed)
self.unavailable = set(unavailable)
self.fail_issue_list = fail_issue_list
self.lookups: list[int] = []
def api_get_all(self, url, _auth, **_kw):
if "/pulls" in url:
return []
if self.fail_issue_list:
raise RuntimeError("issue listing failed")
return list(self.issues)
def api_request(self, _method, url, _auth, **_kw):
number = int(url.rsplit("/", 1)[-1])
self.lookups.append(number)
if number in self.unavailable:
raise RuntimeError("lookup failed")
if number in self.closed:
return {"number": number, "state": "closed"}
return {"number": number, "state": "open"}
class AllocatorInventoryTest(unittest.TestCase):
"""Direct tests of the candidate loader."""
def _load(self, fake, **kwargs):
with patch("gitea_mcp_server._resolve", return_value=("h", ORG, REPO)), patch(
"gitea_mcp_server._auth", return_value=FAKE_AUTH
), patch("gitea_mcp_server.api_get_all", side_effect=fake.api_get_all), patch(
"gitea_mcp_server.api_request", side_effect=fake.api_request
):
return srv._allocator_candidates_from_gitea(
remote="prgs", host=None, org=ORG, repo=REPO, **kwargs
)
def test_full_inventory_above_fifty_is_ranked(self) -> None:
"""AC1: all 73 open issues become candidates, not the first 50."""
fake = _FakeGitea([_issue(n) for n in range(600, 673)])
candidates, _reasons, complete = self._load(fake)
self.assertTrue(complete)
self.assertEqual(len(candidates), 73)
self.assertEqual(min(c.number for c in candidates), 600)
self.assertEqual(max(c.number for c in candidates), 672)
def test_open_dependency_marks_candidate_unmet(self) -> None:
"""AC4/AC5/AC6: canonical Depends on an open issue blocks the candidate."""
fake = _FakeGitea(
[_issue(600, body=_depends_body(601, 602)), _issue(601), _issue(602)]
)
candidates, _reasons, _complete = self._load(fake)
blocked = next(c for c in candidates if c.number == 600)
self.assertTrue(blocked.dependency_unmet)
self.assertIn("#601", blocked.dependency_reason)
def test_closed_dependency_is_eligible(self) -> None:
"""A dependency absent from the open list is confirmed closed, not assumed."""
fake = _FakeGitea([_issue(600, body=_depends_body(500))], closed={500})
candidates, _reasons, _complete = self._load(fake)
candidate = next(c for c in candidates if c.number == 600)
self.assertFalse(candidate.dependency_unmet)
self.assertIn(500, fake.lookups) # proved live, not inferred
def test_unavailable_dependency_evidence_fails_closed(self) -> None:
"""AC7: an unreachable dependency must block, never pass."""
fake = _FakeGitea([_issue(600, body=_depends_body(500))], unavailable={500})
candidates, _reasons, _complete = self._load(fake)
candidate = next(c for c in candidates if c.number == 600)
self.assertTrue(candidate.dependency_unmet)
self.assertIn("fail closed", candidate.dependency_reason)
def test_dependency_state_lookups_are_cached(self) -> None:
"""Repeated references resolve with a single live lookup."""
fake = _FakeGitea(
[_issue(n, body=_depends_body(500)) for n in range(600, 610)],
closed={500},
)
self._load(fake)
self.assertEqual(fake.lookups.count(500), 1)
def test_open_dependency_needs_no_lookup(self) -> None:
"""The complete open listing already proves openness."""
fake = _FakeGitea([_issue(600, body=_depends_body(601)), _issue(601)])
self._load(fake)
self.assertNotIn(601, fake.lookups)
def test_failed_issue_listing_reports_incomplete(self) -> None:
"""AC3: a failed listing must not silently yield a short inventory."""
fake = _FakeGitea([], fail_issue_list=True)
_candidates, reasons, complete = self._load(fake)
self.assertFalse(complete)
self.assertTrue(any("failed to list open issues" in r for r in reasons))
class AllocateNextWorkToolTest(unittest.TestCase):
"""End-to-end tests of the gitea_allocate_next_work MCP tool."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _allocate(self, fake, **kwargs):
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
), patch("gitea_mcp_server._auth", return_value=FAKE_AUTH), patch(
"gitea_mcp_server.get_profile",
return_value={"profile_name": "prgs-author", "role": "author"},
), patch(
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
), patch(
"gitea_mcp_server.api_get_all", side_effect=fake.api_get_all
), patch(
"gitea_mcp_server.api_request", side_effect=fake.api_request
), patch(
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
):
return srv.gitea_allocate_next_work(
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
)
def test_limit_does_not_change_selection(self) -> None:
"""AC2/AC11: the winner is identical at limit=1 and limit=300."""
issues = [_issue(n) for n in range(600, 673)] # 73 candidates
low = self._allocate(_FakeGitea(issues), limit=1)
high = self._allocate(_FakeGitea(issues), limit=300)
self.assertEqual(low["selected"]["number"], high["selected"]["number"])
self.assertEqual(low["selected"]["number"], 600)
self.assertEqual(low["candidate_count"], 73)
self.assertEqual(high["candidate_count"], 73)
def test_dependency_blocked_winner_falls_through(self) -> None:
"""AC8: a blocked highest-ranked issue yields the next eligible one."""
issues = [
_issue(600, body=_depends_body(601)),
_issue(601),
_issue(602),
]
result = self._allocate(_FakeGitea(issues))
# 600 is blocked by open 601; 601 itself is a valid candidate.
self.assertEqual(result["selected"]["number"], 601)
skipped = {s["number"] for s in result["skipped"]}
self.assertIn(600, skipped)
def test_limit_truncates_only_the_reported_skip_list(self) -> None:
"""A shortened report is labelled, never presented as full coverage."""
issues = [_issue(n, body=_depends_body(999)) for n in range(600, 640)]
issues.append(_issue(999)) # open dependency blocks all of the above
issues.append(_issue(700)) # the one eligible candidate
result = self._allocate(_FakeGitea(issues), limit=5)
self.assertTrue(result["skipped_report_truncated"])
self.assertEqual(len(result["skipped"]), 5)
self.assertGreater(result["skipped_total"], 5)
self.assertEqual(result["limit_applies_to"], "reported_skip_list_only")
def test_incomplete_inventory_fails_closed(self) -> None:
"""AC3: no selection is made from a partial candidate set."""
result = self._allocate(_FakeGitea([], fail_issue_list=True))
self.assertFalse(result["success"])
self.assertFalse(result["inventory_complete"])
self.assertIsNone(result["assignment"])
self.assertTrue(
any("fail closed" in r for r in result["reasons"]),
result["reasons"],
)
def test_selection_policy_is_reported(self) -> None:
"""AC10: tie-breaking is stated in the result, not left implicit."""
result = self._allocate(_FakeGitea([_issue(600)]))
self.assertIn("selection_policy", result)
self.assertIn("number asc", result["selection_policy"])
if __name__ == "__main__":
unittest.main()
+349
View File
@@ -0,0 +1,349 @@
"""Allocator pre-rank exclusions and candidates_json transport (#776).
Covers:
* #617 excluded before ranking (never leased when exclude_issue_numbers=[617]);
* excluded top candidate selects the next safe candidate;
* all candidates excluded WAIT, no lease;
* decoded-list and JSON-string candidates_json;
* malformed / type-invalid fail-closed cases;
* dry-run/apply fingerprint match and drift rejection;
* foreign lease and same-owner lease on excluded issue;
* skipped-accounting reason parity (excluded_by_controller);
* public MCP entry-point coverage for exclude_issue_numbers.
"""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from unittest.mock import patch
import gitea_mcp_server as srv
from allocator_service import (
OUTCOME_ASSIGNED,
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
OUTCOME_CANDIDATE_SET_DRIFT,
OUTCOME_NO_SAFE,
OUTCOME_PREVIEW,
OUTCOME_WAIT,
SKIP_CLAIMED_BY_OTHER_SESSION,
SKIP_EXCLUDED_BY_CONTROLLER,
WorkCandidate,
allocate_next_work,
candidate_from_dict,
candidate_set_fingerprint,
normalize_candidates_payload,
normalize_exclude_issue_numbers,
)
from control_plane_db import ControlPlaneDB
REMOTE = "prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
MINE = "ctl-mine-776"
THEIRS = "ctl-theirs-776"
def _issue(number: int, **kwargs) -> WorkCandidate:
base = dict(
kind="issue",
number=number,
state="open",
labels=("status:ready", "type:bug"),
title=f"issue {number}",
priority=20,
)
base.update(kwargs)
return WorkCandidate(**base)
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
return {
"lease_id": f"lease-{number}",
"session_id": session_id,
"controller_instance_id": instance,
"role": "author",
"profile": "prgs-author",
"expires_at": "2026-07-21T12:00:00Z",
"work_kind": kind,
"work_number": number,
}
def _cand_dict(number: int, **kwargs) -> dict:
d = {
"kind": "issue",
"number": number,
"state": "open",
"labels": ["status:ready", "type:bug"],
"title": f"issue {number}",
"priority": 20,
}
d.update(kwargs)
return d
class AllocatorExcludeServiceTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def _alloc(self, candidates, **kwargs):
defaults = dict(
session_id="sess-776",
role="author",
remote=REMOTE,
org=ORG,
repo=REPO,
profile_name="prgs-author",
controller_instance_id=MINE,
claims={},
apply=False,
)
defaults.update(kwargs)
return allocate_next_work(self.db, candidates=candidates, **defaults)
def test_exclude_617_before_ranking_never_selects(self) -> None:
"""AC2/AC8: highest-ranked #617 is removed before ranking."""
cands = [_issue(617), _issue(700), _issue(701)]
res = self._alloc(cands, exclude_issue_numbers=[617])
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
self.assertEqual(res["selected"]["number"], 700)
skipped = {s["number"]: s for s in res["skipped"]}
self.assertIn(617, skipped)
self.assertEqual(
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
)
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, skipped[617]["reason"])
def test_excluded_top_selects_next_safe(self) -> None:
"""AC2: excluding the oldest ready issue promotes the next number."""
cands = [_issue(600), _issue(601), _issue(602)]
res = self._alloc(cands, exclude_issue_numbers=[600])
self.assertEqual(res["selected"]["number"], 601)
def test_all_candidates_excluded_wait_no_lease(self) -> None:
"""AC7: every candidate excluded → WAIT, no assignment."""
cands = [_issue(617), _issue(700)]
res = self._alloc(cands, exclude_issue_numbers=[617, 700], apply=True)
self.assertEqual(res["outcome"], OUTCOME_WAIT)
self.assertIsNone(res["selected"])
self.assertIsNone(res["assignment"])
self.assertEqual(len(res["controller_excluded"]), 2)
def test_omit_exclude_retains_existing_behavior(self) -> None:
"""AC1/AC9: omit exclusions → #617 still wins when oldest ready."""
cands = [_issue(617), _issue(700)]
res = self._alloc(cands)
self.assertEqual(res["selected"]["number"], 617)
self.assertEqual(res.get("exclude_issue_numbers"), [])
def test_foreign_lease_still_skipped(self) -> None:
"""AC6/AC9: foreign claims keep SKIP_CLAIMED_BY_OTHER_SESSION."""
cands = [_issue(617), _issue(700)]
claims = {
("issue", 700): _claim(700, session_id="other", instance=THEIRS),
}
res = self._alloc(
cands, exclude_issue_numbers=[617], claims=claims
)
# 617 excluded, 700 foreign → wait, no selection
self.assertEqual(res["outcome"], OUTCOME_WAIT)
self.assertIsNone(res["selected"])
codes = {s["reason_code"] for s in res["skipped"]}
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, codes)
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, codes)
def test_same_owner_lease_on_excluded_blocks_resume_release(self) -> None:
"""AC5: excluded + live same-owner lease → structured blocker."""
cands = [_issue(617), _issue(700)]
claims = {
("issue", 617): _claim(617, session_id="sess-776", instance=MINE),
}
res = self._alloc(
cands, exclude_issue_numbers=[617], claims=claims, apply=True
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE)
self.assertIsNone(res["assignment"])
self.assertEqual(res["blocked_lease"]["number"], 617)
self.assertIn("resume", res["blocked_lease"]["safe_next_action"])
def test_dry_run_apply_fingerprint_match(self) -> None:
"""AC4: dry-run and apply share the same fingerprint."""
cands = [_issue(617), _issue(700)]
dry = self._alloc(cands, exclude_issue_numbers=[617], apply=False)
apply_res = self._alloc(
cands,
exclude_issue_numbers=[617],
apply=True,
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
)
self.assertEqual(
dry["candidate_set_fingerprint"],
apply_res["candidate_set_fingerprint"],
)
self.assertEqual(apply_res["outcome"], OUTCOME_ASSIGNED)
self.assertEqual(apply_res["selected"]["number"], 700)
def test_apply_rejects_fingerprint_drift(self) -> None:
"""AC4: material candidate-set drift fails closed on apply."""
cands = [_issue(617), _issue(700)]
res = self._alloc(
cands,
exclude_issue_numbers=[617],
apply=True,
expected_candidate_set_fingerprint="0" * 64,
)
self.assertFalse(res["success"])
self.assertEqual(res["outcome"], OUTCOME_CANDIDATE_SET_DRIFT)
self.assertIsNone(res["assignment"])
def test_fingerprint_stable_helper(self) -> None:
cands = [_issue(700), _issue(617)]
a = candidate_set_fingerprint(cands, exclude_issue_numbers=[617])
b = candidate_set_fingerprint(
list(reversed(cands)), exclude_issue_numbers=[617]
)
self.assertEqual(a, b)
def test_normalize_exclude_rejects_bool(self) -> None:
with self.assertRaises(ValueError):
normalize_exclude_issue_numbers([True])
def test_normalize_exclude_rejects_scalar(self) -> None:
with self.assertRaises(ValueError):
normalize_exclude_issue_numbers(617)
class CandidatesJsonNormalizeTest(unittest.TestCase):
def test_decoded_list(self) -> None:
"""AC3: already-decoded list from MCP transport."""
cands = normalize_candidates_payload([_cand_dict(617), _cand_dict(700)])
self.assertEqual([c.number for c in cands], [617, 700])
def test_json_string(self) -> None:
"""AC3: backward-compatible JSON string."""
raw = json.dumps([_cand_dict(617)])
cands = normalize_candidates_payload(raw)
self.assertEqual(cands[0].number, 617)
def test_malformed_json_fail_closed(self) -> None:
with self.assertRaises(ValueError) as ctx:
normalize_candidates_payload("{not json")
self.assertIn("malformed", str(ctx.exception).lower())
def test_scalar_fail_closed(self) -> None:
with self.assertRaises(ValueError):
normalize_candidates_payload(42)
def test_bool_number_fail_closed(self) -> None:
with self.assertRaises(ValueError):
normalize_candidates_payload([_cand_dict(True)]) # type: ignore[arg-type]
def test_invalid_record_fail_closed(self) -> None:
with self.assertRaises(ValueError):
normalize_candidates_payload(["not-a-dict"])
def test_object_not_list_fail_closed(self) -> None:
with self.assertRaises(ValueError):
normalize_candidates_payload(json.dumps({"number": 1}))
def test_candidate_from_dict_rejects_bool_number(self) -> None:
with self.assertRaises(ValueError):
candidate_from_dict({"kind": "issue", "number": True})
class AllocateNextWorkMcpExcludeTest(unittest.TestCase):
"""Public MCP entry-point coverage (#776 AC8)."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _call(self, **kwargs):
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
), patch(
"gitea_mcp_server.get_profile",
return_value={"profile_name": "prgs-author", "role": "author"},
), patch(
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
), patch(
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
):
return srv.gitea_allocate_next_work(
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
)
def test_mcp_exclude_617_decoded_list_never_selects(self) -> None:
"""AC8: public tool with decoded list + exclude_issue_numbers=[617]."""
candidates = [_cand_dict(617), _cand_dict(700)]
res = self._call(
candidates_json=candidates,
exclude_issue_numbers=[617],
apply=False,
)
self.assertTrue(res.get("success"), res)
self.assertEqual(res["selected"]["number"], 700)
skipped = {s["number"]: s for s in res["skipped"]}
self.assertEqual(
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
)
self.assertNotEqual(res["selected"]["number"], 617)
def test_mcp_exclude_617_json_string_apply(self) -> None:
"""AC8: JSON-string transport + apply never leases #617."""
raw = json.dumps([_cand_dict(617), _cand_dict(700)])
res = self._call(
candidates_json=raw,
exclude_issue_numbers=[617],
apply=True,
)
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
self.assertEqual(res["assignment"]["work_number"], 700)
self.assertNotEqual(res["selected"]["number"], 617)
def test_mcp_malformed_candidates_json_fail_closed(self) -> None:
res = self._call(candidates_json="{bad", apply=False)
self.assertFalse(res["success"])
self.assertIsNone(res["assignment"])
self.assertTrue(any("fail closed" in r for r in res["reasons"]))
def test_mcp_bool_number_fail_closed(self) -> None:
res = self._call(
candidates_json=[{"kind": "issue", "number": True, "priority": 20}],
apply=False,
)
self.assertFalse(res["success"])
self.assertIsNone(res["assignment"])
def test_mcp_fingerprint_dry_run_apply_parity(self) -> None:
candidates = [_cand_dict(617), _cand_dict(700)]
dry = self._call(
candidates_json=candidates,
exclude_issue_numbers=[617],
apply=False,
)
apply_res = self._call(
candidates_json=candidates,
exclude_issue_numbers=[617],
apply=True,
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
)
self.assertEqual(
dry["candidate_set_fingerprint"],
apply_res["candidate_set_fingerprint"],
)
self.assertEqual(apply_res["selected"]["number"], 700)
if __name__ == "__main__":
unittest.main()
+11 -1
View File
@@ -238,7 +238,17 @@ class TestSimpleToolAudit(_AuditWiringBase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_close_issue_audited(self, _auth, mock_api):
mock_api.side_effect = [{"state": "closed"}, {"login": "mgr-bot"}]
# Keyed rather than positional: closing an issue also reads its labels
# before and after the state change for the #780 terminal cleanup and
# its read-after-write check, so call order is not a fixed sequence.
def api(method, url, auth, payload=None):
if method == "PATCH":
return {"state": "closed"}
if "/issues/" in url:
return {"number": 42, "labels": []}
return {"login": "mgr-bot"}
mock_api.side_effect = api
with patch.dict(os.environ, self._env(), clear=True):
gitea_close_issue(issue_number=42, remote="prgs")
recs = self._records()
+60 -13
View File
@@ -79,18 +79,33 @@ class TestPreflightIntegration(unittest.TestCase):
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "author"
mcp_server._preflight_resolved_task = None
control_root = "/repo/Gitea-Tools"
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
with mock.patch("gitea_auth.get_profile", return_value={"profile_name": "gitea-author"}):
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
with mock.patch(
"gitea_mcp_server._session_author_lock_worktree",
return_value=None,
):
with mock.patch(
"gitea_auth.get_profile",
return_value={"profile_name": "gitea-author"},
):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
self.assertIn("Branches-only mutation guard", str(ctx.exception))
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
blob = str(ctx.exception)
self.assertTrue(
"Branches-only mutation guard" in blob
or "control checkout" in blob
or "author worktree" in blob.lower()
or "#618" in blob,
msg=blob,
)
def test_verify_preflight_allows_branches_worktree(self):
import mcp_server
@@ -98,14 +113,46 @@ class TestPreflightIntegration(unittest.TestCase):
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "author"
mcp_server._preflight_resolved_task = None
worktree = "/repo/Gitea-Tools/branches/issue-274"
healthy_ctx = {
"workspace_path": worktree,
"workspace_binding_source": "worktree_path argument",
"workspace_role_kind": "author",
"ignored_bindings": [],
"process_project_root": "/repo/Gitea-Tools",
"canonical_repo_root": "/repo/Gitea-Tools",
"roots_aligned": True,
"bound_worktree_missing": False,
"author_worktree_block": False,
"author_worktree_reasons": [],
"author_worktree_resolution": {
"proven": True,
"block": False,
"bound_worktree_missing": False,
"workspace_path": worktree,
"workspace_binding_source": "worktree_path argument",
"reasons": [],
},
"path_exists": True,
"in_git_worktree_list": True,
"inspected_git_root": worktree,
}
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
with mock.patch.object(
mcp_server, "_session_author_lock_worktree", return_value=None
):
mcp_server.verify_preflight_purity(worktree_path=worktree)
with mock.patch.object(
mcp_server,
"_resolve_namespace_mutation_context",
return_value=healthy_ctx,
):
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
mcp_server.verify_preflight_purity(worktree_path=worktree)
if __name__ == "__main__":
+1 -1
View File
@@ -36,7 +36,7 @@ class ControlPlaneDBTest(unittest.TestCase):
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
finally:
conn.close()
self.assertEqual(rows["schema_version"], "3")
self.assertEqual(rows["schema_version"], "4")
self.assertIn("DB coordinates", rows["architecture"])
self.assertIn("bridge", rows["architecture"].lower())
+367
View File
@@ -0,0 +1,367 @@
"""Regression tests for create_issue bootstrap (#749).
TDD: these tests define the sanctioned first-mutation path for
``gitea_create_issue`` from a clean canonical control checkout, and prove
the exemption cannot widen to dirty roots, foreign clones, arbitrary
``branches/`` directories, or post-creation author mutations.
"""
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import create_issue_bootstrap as cib # noqa: E402
import gitea_mcp_server as srv # noqa: E402
import workflow_scope_guard as wsg # noqa: E402
FAKE_AUTH = {"Authorization": "token test-token"}
MASTER_SHA = "a" * 40
STALE_SHA = "b" * 40
current_file_path = Path(__file__).resolve()
if "branches" in current_file_path.parts:
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
else:
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
class TestCreateIssueBootstrapAssessor(unittest.TestCase):
ROOT = "/repo/Gitea-Tools"
def test_non_create_issue_task_not_applicable(self):
res = cib.assess_create_issue_bootstrap(
workspace_path=self.ROOT,
canonical_repo_root=self.ROOT,
current_branch="master",
head_sha=MASTER_SHA,
porcelain_status="",
remote_master_sha=MASTER_SHA,
task="lock_issue",
)
self.assertTrue(res["not_applicable"])
self.assertFalse(res["allowed"])
self.assertFalse(res["block"])
def test_branches_worktree_not_applicable(self):
res = cib.assess_create_issue_bootstrap(
workspace_path=f"{self.ROOT}/branches/issue-1-x",
canonical_repo_root=self.ROOT,
current_branch="fix/issue-1-x",
task="create_issue",
)
self.assertTrue(res["not_applicable"])
self.assertFalse(res["allowed"])
def test_clean_control_checkout_allowed(self):
res = cib.assess_create_issue_bootstrap(
workspace_path=self.ROOT,
canonical_repo_root=self.ROOT,
current_branch="master",
head_sha=MASTER_SHA,
porcelain_status="",
remote_master_sha=MASTER_SHA,
task="create_issue",
)
self.assertFalse(res["not_applicable"])
self.assertTrue(res["allowed"])
self.assertFalse(res["block"])
self.assertEqual(res["bootstrap_path"], "clean_canonical_control_checkout")
# Post-create next action must name issue-backed worktree after N exists.
self.assertIn("branches/issue-<N>-*", res["exact_next_action"])
def test_tool_alias_gitea_create_issue_allowed(self):
res = cib.assess_create_issue_bootstrap(
workspace_path=self.ROOT,
canonical_repo_root=self.ROOT,
current_branch="main",
head_sha=MASTER_SHA,
porcelain_status="",
remote_master_sha=MASTER_SHA,
task="gitea_create_issue",
)
self.assertTrue(res["allowed"])
def test_dirty_control_checkout_blocked(self):
res = cib.assess_create_issue_bootstrap(
workspace_path=self.ROOT,
canonical_repo_root=self.ROOT,
current_branch="master",
head_sha=MASTER_SHA,
porcelain_status=" M gitea_mcp_server.py\n",
remote_master_sha=MASTER_SHA,
task="create_issue",
)
self.assertTrue(res["block"])
self.assertFalse(res["allowed"])
self.assertTrue(any("tracked local edits" in r for r in res["reasons"]))
# Pre-issue phase: next action must be satisfiable without inventing <N>.
next_a = res["exact_next_action"] or ""
self.assertIn("clean accepted base branch", next_a)
self.assertIn("before the issue exists", next_a)
# Must not prescribe "bind branches/issue-<N>" as the recovery step.
self.assertNotIn("Bind an issue-backed worktree", next_a)
def test_stale_base_blocked(self):
res = cib.assess_create_issue_bootstrap(
workspace_path=self.ROOT,
canonical_repo_root=self.ROOT,
current_branch="master",
head_sha=STALE_SHA,
porcelain_status="",
remote_master_sha=MASTER_SHA,
task="create_issue",
)
self.assertTrue(res["block"])
self.assertTrue(any("live master" in r for r in res["reasons"]))
def test_non_base_branch_blocked(self):
res = cib.assess_create_issue_bootstrap(
workspace_path=self.ROOT,
canonical_repo_root=self.ROOT,
current_branch="feat/something",
head_sha=MASTER_SHA,
porcelain_status="",
remote_master_sha=MASTER_SHA,
task="create_issue",
)
self.assertTrue(res["block"])
def test_detached_head_blocked(self):
res = cib.assess_create_issue_bootstrap(
workspace_path=self.ROOT,
canonical_repo_root=self.ROOT,
current_branch="",
head_sha=MASTER_SHA,
porcelain_status="",
remote_master_sha=MASTER_SHA,
task="create_issue",
)
self.assertTrue(res["block"])
self.assertTrue(any("detached" in r for r in res["reasons"]))
def test_foreign_workspace_blocked(self):
res = cib.assess_create_issue_bootstrap(
workspace_path="/other/clone",
canonical_repo_root=self.ROOT,
current_branch="master",
head_sha=MASTER_SHA,
porcelain_status="",
remote_master_sha=MASTER_SHA,
task="create_issue",
)
self.assertTrue(res["block"])
self.assertTrue(any("canonical control checkout" in r for r in res["reasons"]))
class TestCreateIssueBootstrapIntegration(unittest.TestCase):
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_resolved_task = "create_issue"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
srv._preflight_resolved_task = None
def _git_state(self, branch="master", head=MASTER_SHA, porcelain=""):
return {
"current_branch": branch,
"head_sha": head,
"porcelain_status": porcelain,
}
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch(
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch(
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
return_value=MASTER_SHA,
)
def test_clean_control_checkout_create_issue_succeeds(
self, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth
):
mock_api.return_value = {
"number": 99,
"html_url": "https://gitea.example.com/issues/99",
}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=self._git_state(),
):
with patch(
"gitea_mcp_server._get_workspace_porcelain", return_value=""
):
with patch(
"gitea_mcp_server._enforce_root_checkout_guard"
):
# Anti-stomp / master parity: keep gates green.
with patch.object(
srv,
"_run_anti_stomp_preflight",
return_value=None,
):
res = srv.gitea_create_issue(
title="Bootstrap issue from clean control",
body="Body text for content gate.",
)
self.assertEqual(res.get("number"), 99)
mock_api.assert_called_once()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch(
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
def test_dirty_control_checkout_create_issue_fails_closed(
self, _get_all, mock_api, _role, _ns, _prof, _auth
):
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=self._git_state(
porcelain=" M author_mutation_worktree.py\n"
),
):
with patch(
"gitea_mcp_server._get_workspace_porcelain",
return_value=" M author_mutation_worktree.py\n",
):
res = srv.gitea_create_issue(
title="Should fail on dirty root",
body="Body text for content gate.",
)
self.assertFalse(res.get("success", True) and res.get("number"))
if isinstance(res, dict) and res.get("success") is False:
blob = " ".join(res.get("reasons") or [])
self.assertTrue(
"tracked local edits" in blob
or "dirty" in blob.lower()
or "control checkout" in blob.lower()
or res.get("blocker_kind")
)
# Pre-issue phase must not demand issue-<N> worktree.
next_a = res.get("exact_next_action") or ""
if next_a:
self.assertNotIn("issue-<N>-*", next_a)
mock_api.assert_not_called()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch(
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
)
def test_lock_issue_still_requires_branches_worktree(self, _role, _ns, _prof, _auth):
"""Existing issue-backed mutations receive no exemption (#749 AC3/AC7)."""
srv._preflight_resolved_task = "lock_issue"
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=self._git_state(),
):
with patch(
"gitea_mcp_server._get_workspace_porcelain", return_value=""
):
with patch(
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
return_value=MASTER_SHA,
):
with patch.object(
srv, "_enforce_root_checkout_guard"
):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity(
remote="prgs",
task="lock_issue",
)
msg = str(ctx.exception)
self.assertTrue(
"Branches-only mutation guard" in msg
or "stable control checkout" in msg,
msg,
)
self.assertIn("control checkout", msg)
def test_workflow_scope_skips_missing_worktree_for_create_issue_clean_root(self):
"""#683 root assessor must not block clean-root create_issue bootstrap."""
res = wsg.assess_root_source_mutation(
workspace_path=CONTROL_CHECKOUT_ROOT,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
porcelain_status="",
role_kind="author",
mutation_task="create_issue",
)
self.assertFalse(res["block"], res)
self.assertTrue(res.get("create_issue_bootstrap") or res["proven"])
def test_workflow_scope_still_blocks_clean_root_for_lock_issue(self):
res = wsg.assess_root_source_mutation(
workspace_path=CONTROL_CHECKOUT_ROOT,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
porcelain_status="",
role_kind="author",
mutation_task="lock_issue",
)
self.assertTrue(res["block"])
self.assertEqual(res["blocker_kind"], wsg.BLOCKER_MISSING_WORKTREE)
def test_arbitrary_branches_directory_not_bootstrap(self):
"""#713: mkdir fake under branches/ is not the bootstrap path."""
fake = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "fake-mkdir-only")
res = cib.assess_create_issue_bootstrap(
workspace_path=fake,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
current_branch="master",
head_sha=MASTER_SHA,
porcelain_status="",
remote_master_sha=MASTER_SHA,
task="create_issue",
)
# Under branches/ → not bootstrap; ordinary membership/registration applies.
self.assertTrue(res["not_applicable"])
self.assertFalse(res["allowed"])
class TestCreateIssueCapabilityAgreement(unittest.TestCase):
def test_map_and_alias_agree_on_create_issue(self):
import task_capability_map as tcm
self.assertEqual(
tcm.required_permission("create_issue"),
"gitea.issue.create",
)
# Tool alias must resolve to the same task contract.
alias = getattr(tcm, "TOOL_TASK_ALIASES", None) or getattr(
tcm, "TASK_ALIASES", None
)
if alias is not None:
mapped = alias.get("gitea_create_issue")
if mapped is not None:
self.assertEqual(mapped, "create_issue")
if __name__ == "__main__":
unittest.main()
+61 -20
View File
@@ -26,15 +26,23 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_resolved_task = "create_issue"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
# Disable early return in verify_preflight_purity for testing
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
# #618: isolate from ambient session issue locks
self._lock_patch = patch(
"gitea_mcp_server._session_author_lock_worktree", return_value=None
)
self._lock_patch.start()
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
srv._preflight_resolved_task = None
self._lock_patch.stop()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@@ -51,29 +59,62 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
"porcelain_status": "",
},
)
def test_create_issue_stable_checkout_rejected(
def test_create_issue_stable_checkout_bootstrap_allowed_when_clean(
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
):
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
# path is the stable control checkout (not under branches/), mutation must fail.
# #749: clean canonical control checkout is the sanctioned create_issue path.
mock_api.return_value = {
"number": 77,
"html_url": "https://gitea.example.com/issues/77",
}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
try:
res = srv.gitea_create_issue(title="Test issue", body="body text")
except RuntimeError as exc:
self.assertIn("stable control checkout", str(exc))
else:
# #683: production guards return typed blockers at entrypoints
self.assertFalse(res.get("success"))
self.assertFalse(res.get("performed"))
blob = " ".join(res.get("reasons") or []) + " " + str(
res.get("blocker_kind") or ""
)
self.assertTrue(
"stable control checkout" in blob
or "missing_issue_worktree" in blob
or "control checkout" in blob.lower()
)
self.assertTrue(res.get("exact_next_action"))
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
with patch.object(srv, "_run_anti_stomp_preflight", return_value=None):
with patch.object(srv, "_enforce_root_checkout_guard"):
res = srv.gitea_create_issue(
title="Test issue", body="body text for gate"
)
self.assertEqual(res.get("number"), 77)
mock_api.assert_called_once()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
@patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"head_sha": "a" * 40,
"porcelain_status": " M dirty.py\n",
},
)
def test_create_issue_dirty_control_checkout_rejected(
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
):
# #749: dirty control checkout still fails closed (no bootstrap).
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch(
"gitea_mcp_server._get_workspace_porcelain",
return_value=" M dirty.py\n",
):
try:
res = srv.gitea_create_issue(title="Test issue", body="body text")
except RuntimeError as exc:
self.assertTrue(
"tracked local edits" in str(exc)
or "dirty" in str(exc).lower()
or "bootstrap" in str(exc).lower()
or "control checkout" in str(exc).lower()
)
else:
self.assertFalse(res.get("success", True) and res.get("number"))
blob = " ".join(res.get("reasons") or [])
self.assertTrue(blob or res.get("blocker_kind"))
mock_api.assert_not_called()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@@ -0,0 +1,349 @@
"""Regression tests for durable author worktree resolution (#618)."""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import author_mutation_worktree as amw # noqa: E402
import gitea_mcp_server as srv # noqa: E402
import namespace_workspace_binding as nwb # noqa: E402
FAKE_AUTH = {"Authorization": "token test-token"}
current_file_path = Path(__file__).resolve()
if "branches" in current_file_path.parts:
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
else:
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
class TestDurableAuthorWorktreeResolution(unittest.TestCase):
def test_missing_author_env_fails_closed_no_control_fallback(self):
missing = "/nonexistent/branches/mcp-author-clean-ns"
result = amw.resolve_durable_author_worktree(
process_project_root=CONTROL_CHECKOUT_ROOT,
author_worktree_env=missing,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
)
self.assertTrue(result["block"])
self.assertTrue(result["bound_worktree_missing"])
self.assertFalse(result["silent_control_fallback"])
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, result["reasons"][0])
self.assertNotEqual(
os.path.realpath(result["workspace_path"]),
os.path.realpath(CONTROL_CHECKOUT_ROOT),
)
def test_missing_active_env_fails_closed(self):
missing = "/nonexistent/branches/deleted-active"
result = amw.resolve_durable_author_worktree(
process_project_root=CONTROL_CHECKOUT_ROOT,
active_worktree_env=missing,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
)
self.assertTrue(result["block"])
self.assertTrue(result["bound_worktree_missing"])
self.assertIn(amw.ACTIVE_WORKTREE_ENV, result["workspace_binding_source"])
def test_derives_from_active_author_issue_lock(self):
lock_wt = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-lock")
result = amw.resolve_durable_author_worktree(
process_project_root=CONTROL_CHECKOUT_ROOT,
session_lock_worktree=lock_wt,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
validate=False,
)
self.assertEqual(
result["workspace_path"], os.path.realpath(os.path.abspath(lock_wt))
)
self.assertIn("issue lock", result["workspace_binding_source"])
def test_explicit_worktree_path_wins_over_lock(self):
explicit = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-explicit")
lock_wt = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-lock")
result = amw.resolve_durable_author_worktree(
worktree_path=explicit,
process_project_root=CONTROL_CHECKOUT_ROOT,
session_lock_worktree=lock_wt,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
validate=False,
)
self.assertEqual(
result["workspace_path"], os.path.realpath(os.path.abspath(explicit))
)
self.assertEqual(result["workspace_binding_source"], "worktree_path argument")
def test_no_binding_does_not_silently_use_control_checkout(self):
result = amw.resolve_durable_author_worktree(
process_project_root=CONTROL_CHECKOUT_ROOT,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
)
self.assertTrue(result["block"])
self.assertFalse(result["silent_control_fallback"])
blob = " ".join(result["reasons"])
self.assertIn("control checkout", blob)
self.assertIn("forbidden", blob)
def test_process_root_under_branches_is_allowed(self):
branches_root = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "session-wt")
result = amw.resolve_durable_author_worktree(
process_project_root=branches_root,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
validate=False,
)
self.assertFalse(result["block"])
self.assertEqual(
result["workspace_path"], os.path.realpath(branches_root)
)
self.assertIn("branches/", result["workspace_binding_source"])
def test_lock_ownership_mismatch_fails_closed(self):
with tempfile.TemporaryDirectory() as tmp:
root = tmp
branches = os.path.join(root, "branches")
os.makedirs(os.path.join(branches, "a"))
os.makedirs(os.path.join(branches, "b"))
# Seed a fake .git so membership/list may soft-fail without hard error
os.makedirs(os.path.join(root, ".git"))
result = amw.resolve_durable_author_worktree(
worktree_path=os.path.join(branches, "a"),
process_project_root=root,
session_lock_worktree=os.path.join(branches, "b"),
canonical_repo_root=root,
validate=True,
)
self.assertTrue(result["block"])
self.assertTrue(
any("lock" in r.lower() and "match" in r.lower() for r in result["reasons"])
)
def test_traversal_safety_blocks_escape(self):
assessment = amw.assess_path_traversal_safety(
path="/tmp/other-repo/branches/evil",
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
)
self.assertTrue(assessment["block"])
self.assertTrue(any("escapes" in r for r in assessment["reasons"]))
def test_bound_worktree_existence_reports_null_git_root(self):
assessment = amw.assess_bound_worktree_existence(
configured_path="/nonexistent/branches/gone",
binding_source=f"{amw.AUTHOR_WORKTREE_ENV} environment variable",
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
profile_name="prgs-author",
)
self.assertTrue(assessment["block"])
self.assertIsNone(assessment["inspected_git_root"])
self.assertFalse(assessment["path_exists"])
msg = amw.format_bound_worktree_missing_error(assessment)
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, msg)
self.assertIn("prgs-author", msg)
self.assertIn("recreate or repoint", msg.lower())
class TestNamespaceAuthorNoDemotion(unittest.TestCase):
def test_author_missing_env_not_demoted_to_process_root(self):
missing = "/nonexistent/branches/mcp-author-clean-ns"
demotions: list[str] = []
path, source = nwb.resolve_namespace_workspace(
role_kind="author",
process_project_root=CONTROL_CHECKOUT_ROOT,
env={amw.AUTHOR_WORKTREE_ENV: missing},
demotions=demotions,
verify_paths=True,
)
self.assertIn("AUTHOR", source)
self.assertNotEqual(os.path.realpath(path), os.path.realpath(CONTROL_CHECKOUT_ROOT))
self.assertTrue(any("not demoted" in d for d in demotions))
def test_reviewer_still_demotes_missing_env(self):
"""#702 demotion retained for non-author roles."""
demotions: list[str] = []
path, source = nwb.resolve_namespace_workspace(
role_kind="reviewer",
process_project_root=CONTROL_CHECKOUT_ROOT,
env={"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-gone"},
demotions=demotions,
verify_paths=True,
)
self.assertEqual(source, "MCP server process root (default)")
self.assertEqual(path, os.path.realpath(CONTROL_CHECKOUT_ROOT))
self.assertTrue(demotions)
def test_mutation_context_surfaces_missing_binding_health(self):
ctx = nwb.resolve_namespace_mutation_context(
role_kind="author",
worktree_path=None,
process_project_root=CONTROL_CHECKOUT_ROOT,
env={amw.AUTHOR_WORKTREE_ENV: "/nonexistent/branches/mcp-author-clean-ns"},
profile_name="prgs-author",
)
self.assertTrue(ctx.get("bound_worktree_missing"))
self.assertTrue(ctx.get("author_worktree_block"))
self.assertIsNone(ctx.get("inspected_git_root"))
self.assertFalse(ctx.get("path_exists"))
class TestCreateIssueAndCommentAgreeOnMissingWorktree(unittest.TestCase):
"""AC3/AC4: create_issue and create_issue_comment enforce the same rule."""
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_resolved_task = None
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
self._lock_patch = patch(
"gitea_mcp_server._session_author_lock_worktree", return_value=None
)
self._lock_patch.start()
self.addCleanup(self._restore)
def _restore(self):
srv._preflight_in_test_mode = self._orig_in_test
srv._preflight_resolved_task = None
self._lock_patch.stop()
os.environ.pop(amw.AUTHOR_WORKTREE_ENV, None)
os.environ.pop(amw.ACTIVE_WORKTREE_ENV, None)
def _assert_blocked_missing(self, result_or_exc):
if isinstance(result_or_exc, BaseException):
blob = str(result_or_exc)
else:
blob = " ".join(
str(x)
for x in (
result_or_exc.get("reasons") or [],
result_or_exc.get("message"),
result_or_exc.get("blocker_kind"),
)
if x
)
if not blob:
blob = str(result_or_exc)
self.assertTrue(
amw.BOUND_WORKTREE_MISSING_MESSAGE in blob
or "does not exist" in blob
or "bound worktree" in blob.lower(),
msg=blob,
)
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch(
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
def test_create_issue_blocked_when_author_env_missing(
self, _get_all, mock_api, _role, _ns, _prof, _auth
):
missing = os.path.join(
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-618-author-env"
)
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
srv._preflight_resolved_task = "create_issue"
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
try:
res = srv.gitea_create_issue(title="Test issue", body="body text here")
except RuntimeError as exc:
self._assert_blocked_missing(exc)
else:
self.assertFalse(res.get("success", True) and res.get("number"))
self._assert_blocked_missing(res)
mock_api.assert_not_called()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
def test_create_issue_comment_blocked_when_author_env_missing(self, mock_api, _auth):
missing = os.path.join(
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-618-author-env"
)
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
srv._preflight_resolved_task = "comment_issue"
author_env = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
amw.AUTHOR_WORKTREE_ENV: missing,
}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch.dict(os.environ, author_env, clear=False):
try:
res = srv.gitea_create_issue_comment(
issue_number=618,
body="evidence comment",
remote="prgs",
)
except RuntimeError as exc:
self._assert_blocked_missing(exc)
else:
self.assertFalse(res.get("success", True))
self._assert_blocked_missing(res)
mock_api.assert_not_called()
class TestRuntimeContextUnhealthyMissingWorktree(unittest.TestCase):
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
self._lock_patch = patch(
"gitea_mcp_server._session_author_lock_worktree", return_value=None
)
self._lock_patch.start()
def tearDown(self):
self._lock_patch.stop()
os.environ.pop(amw.AUTHOR_WORKTREE_ENV, None)
def test_assess_preflight_reports_null_git_root_and_missing(self):
missing = "/nonexistent/branches/mcp-author-clean-ns"
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server.get_profile", return_value={
"profile_name": "prgs-author",
"allowed_operations": ["gitea.pr.create"],
"forbidden_operations": [],
}):
status = srv.assess_preflight_status()
self.assertFalse(status["preflight_ready"])
blob = " ".join(status["preflight_block_reasons"])
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, blob)
details = status["preflight_workspace"]
self.assertIsNotNone(details)
self.assertTrue(details.get("bound_worktree_missing"))
self.assertIsNone(details.get("inspected_git_root"))
self.assertFalse(details.get("path_exists"))
self.assertFalse(details.get("workspace_healthy"))
class TestThreadLedgerExample(unittest.TestCase):
def test_bound_worktree_missing_ledger_example_exists(self):
import thread_state_ledger_examples as examples
names = [name for name, _h, _l in examples.EXAMPLES]
self.assertIn("bound_worktree_missing_blocker", names)
for name, _handoff, ledger in examples.EXAMPLES:
if name == "bound_worktree_missing_blocker":
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, ledger)
self.assertIn("inspected_git_root", ledger)
self.assertIn("operator", ledger.lower())
break
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,491 @@
"""Manual MCP daemon-kill contamination guard (#630).
Covers the four scenarios the acceptance criteria name manual process kill,
sanctioned reconnect, stale-runtime restart, and a contaminated post-restart
mutation across the pure guard, the durable marker, the MCP tools, the
pre-flight enforcement gate, and the final-report rules.
"""
from __future__ import annotations
import os
from unittest.mock import patch
import final_report_validator
import mcp_session_state
import runtime_recovery_guard as guard
import gitea_mcp_server as srv
AUTH_ENV = guard.OPERATOR_AUTHORIZATION_ENV
def _clear_marker(remote="prgs"):
srv._clear_runtime_recovery_marker(remote=remote)
def teardown_function():
_clear_marker()
def _marker(reason_class=guard.REASON_MANUAL_DAEMON_KILL, **overrides):
record = guard.build_contamination_record(
reason_class=reason_class,
command_redacted="pkill -f mcp_server.py",
session_id="prgs-author-1234-abcd",
remote="prgs",
role="author",
detail="manual daemon kill",
)
record.update(overrides)
return record
# ── AC1/AC2: manual process kill is detected and classified ──────────────────
def test_pkill_mcp_server_py_is_contamination():
result = guard.classify_recovery_command("pkill -f mcp_server.py")
assert result["process_kill"] is True
assert result["contamination"] is True
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
assert result["ambiguous"] is False
def test_equivalent_kill_forms_are_contamination():
for command in (
"pkill -f gitea_mcp_server",
"pkill -f mcp",
"pkill -9 -f mcp_server.py",
"killall mcp_server",
"sudo pkill -f mcp_server.py",
"killall -9 mcp-server",
):
result = guard.classify_recovery_command(command)
assert result["contamination"] is True, command
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL, command
def test_broad_pattern_is_collateral_damage_contamination():
result = guard.classify_recovery_command("pkill -f python")
assert result["contamination"] is True
assert result["reason_class"] == guard.REASON_BROAD_PROCESS_KILL
assert "collateral" in " ".join(result["reasons"])
def test_kill_of_known_mcp_pid_is_contamination():
result = guard.classify_recovery_command("kill -9 4242", mcp_pids=[4242, 99])
assert result["contamination"] is True
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
assert "4242" in " ".join(result["reasons"])
def test_kill_resolved_from_mcp_lookup_is_contamination():
result = guard.classify_recovery_command("kill $(pgrep -f mcp_server.py)")
assert result["contamination"] is True
def test_compound_command_detects_the_kill_half():
result = guard.classify_recovery_command(
"ps aux | grep mcp_server && pkill -f mcp_server.py"
)
assert result["contamination"] is True
# ── no false positives ───────────────────────────────────────────────────────
def test_read_only_inspection_is_not_a_kill():
result = guard.classify_recovery_command("ps aux | grep mcp_server")
assert result["process_kill"] is False
assert result["contamination"] is False
def test_grepping_for_pkill_is_not_a_kill():
result = guard.classify_recovery_command('grep -rn "pkill" native_mcp_preference.py')
assert result["process_kill"] is False
assert result["contamination"] is False
def test_unrelated_pkill_target_is_not_contamination():
result = guard.classify_recovery_command("pkill -f my-dev-server")
assert result["process_kill"] is True
assert result["contamination"] is False
assert result["ambiguous"] is False
def test_bare_kill_of_unknown_pid_is_ambiguous_not_contamination():
result = guard.classify_recovery_command("kill 31337")
assert result["contamination"] is False
assert result["ambiguous"] is True
assert "not known MCP" in " ".join(result["reasons"])
def test_kill_without_pid_is_ambiguous():
result = guard.classify_recovery_command("kill")
assert result["contamination"] is False
assert result["ambiguous"] is True
def test_empty_command_is_inert():
result = guard.classify_recovery_command(None)
assert result["command_present"] is False
assert result["process_kill"] is False
assert result["contamination"] is False
# ── sanctioned reconnect / restart ───────────────────────────────────────────
def test_sanctioned_reconnect_is_not_contamination():
result = guard.classify_recovery_command(
"/mcp reconnect then re-run gitea_whoami"
)
assert result["sanctioned_recovery"] is True
assert result["contamination"] is False
assert result["process_kill"] is False
def test_stale_runtime_restart_language_is_not_contamination():
result = guard.classify_recovery_command(
"runtime is stale against master; relaunch the IDE client so the "
"namespaces restart"
)
assert result["sanctioned_recovery"] is True
assert result["contamination"] is False
def test_sanctioned_language_never_excuses_an_actual_kill():
result = guard.classify_recovery_command(
"client reconnect did not help; pkill -f mcp_server.py"
)
assert result["sanctioned_recovery"] is True
assert result["contamination"] is True
# ── operator authorization (env-only, never self-assertable) ─────────────────
def test_operator_authorization_absent_by_default():
auth = guard.operator_authorization(env={})
assert auth["authorized"] is False
assert auth["reference"] is None
assert auth["self_assertable"] is False
def test_operator_authorization_read_from_env_only():
auth = guard.operator_authorization(env={AUTH_ENV: "CHG-4471 host maintenance"})
assert auth["authorized"] is True
assert auth["reference"] == "CHG-4471 host maintenance"
assert auth["source"] == AUTH_ENV
def test_authorized_maintenance_is_not_contamination():
assessment = guard.assess_recovery_command(
"pkill -f mcp_server.py",
env={AUTH_ENV: "CHG-4471"},
)
assert assessment["classification"]["contamination"] is True
assert assessment["contaminated"] is False
assert assessment["authorized_bypass"] is True
assert assessment["remediation"] is None
def test_unauthorized_kill_is_contamination():
assessment = guard.assess_recovery_command("pkill -f mcp_server.py", env={})
assert assessment["contaminated"] is True
assert assessment["authorized_bypass"] is False
assert assessment["remediation"]
# ── redaction ────────────────────────────────────────────────────────────────
def test_marker_and_classification_redact_secrets():
command = "GITEA_TOKEN=supersecretvalue pkill -f mcp_server.py"
result = guard.classify_recovery_command(command)
assert "supersecretvalue" not in result["redacted_command"]
assert "GITEA_TOKEN=***" in result["redacted_command"]
record = guard.build_contamination_record(
reason_class=guard.REASON_MANUAL_DAEMON_KILL,
command_redacted=result["redacted_command"],
)
assert "supersecretvalue" not in record["command_summary"]
assert record["cleared_by_reconciler"] is False
# ── AC3: gate over the gated mutation set ────────────────────────────────────
def test_gate_blocks_gated_tasks():
marker = _marker()
for task in ("merge_pr", "review_pr", "close_issue", "create_pr", "submit_pr_review"):
gate = guard.assess_contamination_gate(marker, task=task, actual_role="author")
assert gate["block"] is True, task
def test_gate_allows_handoff_tasks():
marker = _marker()
for task in ("comment_issue", "lock_issue"):
gate = guard.assess_contamination_gate(marker, task=task, actual_role="author")
assert gate["block"] is False, task
def test_gate_exempts_reconciler():
gate = guard.assess_contamination_gate(
_marker(), task="merge_pr", actual_role="reconciler"
)
assert gate["block"] is False
def test_gate_allows_when_no_marker_or_cleared():
assert guard.assess_contamination_gate(
None, task="merge_pr", actual_role="author"
)["block"] is False
cleared = _marker(cleared_by_reconciler=True)
assert guard.assess_contamination_gate(
cleared, task="merge_pr", actual_role="author"
)["block"] is False
def test_gate_error_message_names_the_issue():
gate = guard.assess_contamination_gate(
_marker(), task="merge_pr", actual_role="author"
)
assert "#630" in guard.format_contamination_gate_error(gate)
# ── scope item 4: final-report rules ─────────────────────────────────────────
def test_final_report_clean_claim_is_rejected():
result = guard.assess_final_report_claim(
"Runtime recovery: manual daemon kill occurred. Otherwise a clean session.",
_marker(),
)
assert result["block"] is True
assert result["clean_claim"] is True
def test_final_report_must_surface_the_contamination():
result = guard.assess_final_report_claim(
"All acceptance criteria met; tests pass.", _marker()
)
assert result["block"] is True
assert result["surfaced"] is False
def test_final_report_that_surfaces_and_claims_nothing_clean_passes():
result = guard.assess_final_report_claim(
"This session performed a manual daemon kill of the MCP processes and "
"is workflow-contaminated pending a reconciler audit.",
_marker(),
)
assert result["block"] is False
assert result["surfaced"] is True
def test_final_report_unconstrained_without_marker():
result = guard.assess_final_report_claim("clean session", None)
assert result["block"] is False
assert result["contaminated"] is False
def test_validator_blocks_clean_claim_while_contaminated():
out = final_report_validator.assess_final_report_validator(
"Merged the PR. No contamination in this session.",
"merge_pr",
runtime_recovery_marker=_marker(),
)
assert out["blocked"] is True
assert any(
finding["rule_id"] == "shared.runtime_recovery_contamination"
for finding in out["findings"]
)
def test_validator_default_is_unchanged_without_marker():
out = final_report_validator.assess_final_report_validator(
"Merged the PR. No contamination in this session.",
"merge_pr",
)
assert "runtime_recovery_contamination" not in out["checks"]
assert not any(
finding["rule_id"] == "shared.runtime_recovery_contamination"
for finding in out["findings"]
)
# ── durable marker must outlive the session TTL ──────────────────────────────
def test_contamination_marker_is_recovery_critical():
assert (
mcp_session_state.KIND_RUNTIME_RECOVERY_CONTAMINATION
in mcp_session_state.RECOVERY_CRITICAL_KINDS
)
# ── server wiring: record tool ───────────────────────────────────────────────
def test_record_tool_marks_manual_daemon_kill():
_clear_marker()
res = srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs"
)
assert res["contaminated"] is True
assert res["marked"] is True
assert res["marker"]["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
loaded = srv._load_runtime_recovery_marker("prgs")
assert loaded is not None
assert "mcp_server.py" in loaded["command_summary"]
def test_record_tool_marks_broad_sweep():
_clear_marker()
res = srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f python", remote="prgs"
)
assert res["contaminated"] is True
assert res["marker"]["reason_class"] == guard.REASON_BROAD_PROCESS_KILL
def test_record_tool_marks_known_pid_kill():
_clear_marker()
res = srv.gitea_record_daemon_process_kill_attempt(
command="kill -9 4242", mcp_pids=["4242"], remote="prgs"
)
assert res["contaminated"] is True
assert res["marked"] is True
def test_record_tool_does_not_mark_inspection():
_clear_marker()
res = srv.gitea_record_daemon_process_kill_attempt(
command="ps aux | grep mcp_server", remote="prgs"
)
assert res["contaminated"] is False
assert res["marked"] is False
assert srv._load_runtime_recovery_marker("prgs") is None
def test_record_tool_does_not_mark_sanctioned_reconnect():
_clear_marker()
res = srv.gitea_record_daemon_process_kill_attempt(
command="/mcp reconnect", remote="prgs"
)
assert res["contaminated"] is False
assert res["marked"] is False
assert srv._load_runtime_recovery_marker("prgs") is None
def test_record_tool_mark_false_is_read_only():
_clear_marker()
res = srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs", mark=False
)
assert res["contaminated"] is True
assert res["marked"] is False
assert srv._load_runtime_recovery_marker("prgs") is None
def test_record_tool_honours_operator_authorization():
_clear_marker()
with patch.dict(os.environ, {AUTH_ENV: "CHG-4471"}):
res = srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs"
)
assert res["authorized_bypass"] is True
assert res["contaminated"] is False
assert res["marked"] is False
assert srv._load_runtime_recovery_marker("prgs") is None
# ── server wiring: audit tool ────────────────────────────────────────────────
def test_audit_inspect_reports_marker():
_clear_marker()
srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs"
)
out = srv.gitea_audit_runtime_recovery_contamination(action="inspect", remote="prgs")
assert out["contaminated"] is True
assert out["read_only"] is True
def test_audit_clear_refused_for_non_reconciler():
_clear_marker()
srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs"
)
with patch.object(srv, "_actual_profile_role", return_value="author"):
out = srv.gitea_audit_runtime_recovery_contamination(
action="clear", remote="prgs"
)
assert out["success"] is False
assert out["reasons"]
assert srv._load_runtime_recovery_marker("prgs") is not None
def test_audit_clear_allowed_for_reconciler():
_clear_marker()
srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs"
)
identity = srv._runtime_recovery_profile_identity()
with patch.object(srv, "_actual_profile_role", return_value="reconciler"):
out = srv.gitea_audit_runtime_recovery_contamination(
action="clear", remote="prgs", profile_identity=identity
)
assert out["success"] is True
assert srv._load_runtime_recovery_marker("prgs") is None
def test_audit_unknown_action_fails_closed():
out = srv.gitea_audit_runtime_recovery_contamination(action="nuke", remote="prgs")
assert out["success"] is False
assert out["performed"] is False
# ── AC3/AC4: contaminated post-restart mutation fails closed ─────────────────
def _force_gate_env():
return patch.dict(os.environ, {"GITEA_TEST_FORCE_RUNTIME_CONTAMINATION": "1"})
def test_gate_blocks_mutations_after_manual_kill_and_restart():
_clear_marker()
# The session kills the daemons, the IDE respawns them, and the session then
# attempts the mutations #601 was closed with.
srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs"
)
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
for task in ("merge_pr", "review_pr", "close_issue", "create_pr"):
try:
srv._enforce_runtime_recovery_contamination_gate(task, "prgs")
raised = False
except RuntimeError as exc:
raised = True
assert "#630" in str(exc)
assert raised, task
def test_gate_allows_handoff_comment_when_contaminated():
_clear_marker()
srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs"
)
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
srv._enforce_runtime_recovery_contamination_gate("comment_issue", "prgs")
srv._enforce_runtime_recovery_contamination_gate("lock_issue", "prgs")
def test_gate_exempts_reconciler_audit():
_clear_marker()
srv.gitea_record_daemon_process_kill_attempt(
command="pkill -f mcp_server.py", remote="prgs"
)
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="reconciler"):
srv._enforce_runtime_recovery_contamination_gate("merge_pr", "prgs")
def test_gate_noop_after_sanctioned_restart_only():
_clear_marker()
srv.gitea_record_daemon_process_kill_attempt(
command="/mcp reconnect", remote="prgs"
)
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
srv._enforce_runtime_recovery_contamination_gate("merge_pr", "prgs")
@@ -0,0 +1,308 @@
"""Regression coverage for issue #723 role and capability invariants."""
from __future__ import annotations
import unittest
from unittest.mock import patch
import gitea_mcp_server as mcp_server
import task_capability_map
REVIEWER_PROFILE = {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": [
"gitea.read",
"gitea.pr.review",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.comment",
"gitea.issue.comment",
],
"forbidden_operations": [
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
"gitea.pr.create",
"gitea.pr.merge",
],
}
CONFIG = {
"profiles": {
"prgs-reviewer": {
"role": "reviewer",
"allowed_operations": REVIEWER_PROFILE["allowed_operations"],
"forbidden_operations": REVIEWER_PROFILE["forbidden_operations"],
},
"prgs-merger": {
"role": "merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.merge",
"gitea.pr.comment",
"gitea.issue.comment",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.review",
"gitea.pr.request_changes",
],
},
}
}
def _reset_preflight() -> None:
mcp_server._clear_preflight_capability_state()
mcp_server._preflight_whoami_called = False
mcp_server._preflight_whoami_violation = False
mcp_server.capability_stop_terminal.clear()
mcp_server.role_session_router.clear_route_state()
class _ResolveHarness(unittest.TestCase):
def setUp(self):
_reset_preflight()
def tearDown(self):
_reset_preflight()
def _resolve(
self,
task,
profile=REVIEWER_PROFILE,
required_role=None,
init_side_effect=None,
):
patches = [
patch.object(mcp_server, "get_profile", return_value=profile),
patch.object(
mcp_server.gitea_config, "load_config", return_value=CONFIG
),
patch.object(
mcp_server, "_authenticated_username", return_value="tester"
),
patch.object(
mcp_server,
"init_review_decision_lock",
return_value=None,
side_effect=init_side_effect,
),
patch.object(
mcp_server, "record_mutation_authority", return_value=None
),
patch.object(
mcp_server, "_check_mcp_runtimes_diagnostics", return_value=[]
),
]
if required_role is not None:
patches.append(
patch.object(
mcp_server.task_capability_map,
"required_role",
side_effect=lambda candidate: (
required_role
if candidate == task
else task_capability_map.TASK_CAPABILITY_MAP[candidate][
"role"
]
),
)
)
for context in patches:
context.__enter__()
try:
return mcp_server.gitea_resolve_task_capability(
task=task, remote="prgs"
)
finally:
for context in reversed(patches):
context.__exit__(None, None, None)
class TestCapabilityRoleStampSafety(_ResolveHarness):
def test_allowed_resolution_records_the_correct_stamp(self):
result = self._resolve("review_pr")
self.assertTrue(result["allowed_in_current_session"], result)
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
def test_denied_resolution_records_no_stamp(self):
with patch.object(
mcp_server,
"record_preflight_check",
wraps=mcp_server.record_preflight_check,
) as record:
result = self._resolve("review_pr", required_role="merger")
self.assertFalse(result["allowed_in_current_session"], result)
stamped_calls = [
call
for call in record.call_args_list
if len(call.args) > 1 and call.args[1] is not None
]
self.assertEqual(
stamped_calls,
[],
"a denied resolution must never transiently record a role stamp",
)
self.assertIsNone(mcp_server._preflight_resolved_role)
self.assertIsNone(mcp_server._preflight_resolved_task)
def test_denied_resolution_clears_an_existing_stamp(self):
allowed = self._resolve("review_pr")
self.assertTrue(allowed["allowed_in_current_session"], allowed)
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
denied = self._resolve("merge_pr")
self.assertFalse(denied["allowed_in_current_session"], denied)
self.assertIsNone(mcp_server._preflight_resolved_role)
self.assertIsNone(mcp_server._preflight_resolved_task)
def test_denial_cannot_poison_a_later_allowed_task(self):
denied = self._resolve("merge_pr")
self.assertFalse(denied["allowed_in_current_session"], denied)
allowed = self._resolve("review_pr")
self.assertTrue(allowed["allowed_in_current_session"], allowed)
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
def test_unexpected_resolver_failure_leaves_no_stamp(self):
with self.assertRaisesRegex(RuntimeError, "malformed decision state"):
self._resolve(
"review_pr",
init_side_effect=RuntimeError("malformed decision state"),
)
self.assertIsNone(mcp_server._preflight_resolved_role)
self.assertIsNone(mcp_server._preflight_resolved_task)
class TestStructuredWorkspaceRoleFailures(unittest.TestCase):
def test_review_submission_returns_workspace_role_binding_failure(self):
error = RuntimeError(
"namespace workspace binding blocked: merger role in reviewer workspace"
)
with patch.object(
mcp_server, "_verify_role_mutation_workspace", side_effect=error
):
result = mcp_server._evaluate_pr_review_submission(
pr_number=721,
action="approve",
expected_head_sha="8" * 40,
remote="prgs",
live=True,
final_review_decision_ready=True,
)
self.assertFalse(result["performed"])
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
self.assertTrue(
any("workspace/role binding failed" in reason for reason in result["reasons"]),
result,
)
self.assertTrue(any("merger role" in reason for reason in result["reasons"]))
def test_adopt_merger_lease_returns_workspace_role_binding_failure(self):
error = RuntimeError("merger workspace binding rejected")
with patch.object(
mcp_server, "_profile_operation_gate", return_value=[]
), patch.object(
mcp_server, "_verify_role_mutation_workspace", side_effect=error
), patch.object(mcp_server, "_resolve") as resolve:
result = mcp_server.gitea_adopt_merger_pr_lease(
pr_number=718,
worktree="branches/merge-pr-718",
expected_head_sha="7" * 40,
remote="prgs",
)
self.assertFalse(result["success"])
self.assertFalse(result["adopted"])
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
self.assertEqual(result["pr_number"], 718)
self.assertEqual(result["expected_head_sha"], "7" * 40)
self.assertIsNone(result["live_head_sha"])
self.assertTrue(any("binding rejected" in reason for reason in result["reasons"]))
resolve.assert_not_called()
def test_unexpected_verifier_failure_remains_fail_closed(self):
with patch.object(
mcp_server,
"_verify_role_mutation_workspace",
side_effect=ValueError("unexpected verifier state"),
), patch.object(mcp_server, "_resolve") as resolve:
with self.assertRaisesRegex(ValueError, "unexpected verifier state"):
mcp_server._evaluate_pr_review_submission(
pr_number=721,
action="approve",
remote="prgs",
live=True,
)
resolve.assert_not_called()
class TestRuntimeCapabilityRoleFiltering(unittest.TestCase):
def test_runtime_role_filter_denies_permission_bearing_wrong_role(self):
allowed = REVIEWER_PROFILE["allowed_operations"] + ["gitea.pr.merge"]
capabilities = mcp_server._build_runtime_task_capabilities(
allowed,
[],
CONFIG,
remote="prgs",
active_role_kind="reviewer",
)
merge_entry = next(
item
for item in capabilities["task_capabilities"]
if item["task"] == "merge_pr"
)
self.assertTrue(merge_entry["role_exclusive"])
self.assertEqual(merge_entry["capability_view"], "role_filtered")
self.assertFalse(merge_entry["allowed_in_current_session"])
self.assertFalse(capabilities["can_merge_prs"])
def test_permission_only_view_is_explicit(self):
capabilities = mcp_server._build_runtime_task_capabilities(
["gitea.read", "gitea.pr.merge"],
[],
CONFIG,
active_role_kind=None,
)
merge_entry = next(
item
for item in capabilities["task_capabilities"]
if item["task"] == "merge_pr"
)
self.assertEqual(merge_entry["capability_view"], "permission_only")
self.assertTrue(merge_entry["allowed_in_current_session"])
def test_matching_profiles_honor_declared_roles(self):
capabilities = mcp_server._build_runtime_task_capabilities(
["gitea.read"],
[],
CONFIG,
active_role_kind="author",
)
review_entry = next(
item
for item in capabilities["task_capabilities"]
if item["task"] == "review_pr"
)
merge_entry = next(
item
for item in capabilities["task_capabilities"]
if item["task"] == "merge_pr"
)
self.assertEqual(
review_entry["matching_configured_profiles"], ["prgs-reviewer"]
)
self.assertEqual(
merge_entry["matching_configured_profiles"], ["prgs-merger"]
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,658 @@
"""Reconciler role binding for post-merge moot-lease cleanup (#745).
``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase:
released`` lease marker but had no capability-map entry and no role gate: entry
required only ``gitea.read``, apply required only ``gitea.pr.comment``, so any
profile holding the comment permission reached the mutation while the
reconciler could not satisfy resolve-exact-task -> mutation.
These tests pin the fixed contract:
- the canonical task and its tool-name alias resolve identically
(``gitea.pr.comment`` + ``reconciler``);
- only a reconciler profile satisfies permission AND role;
- ``apply=false`` assessment stays reachable under ``gitea.read`` for any role
and mutates nothing (documented, deliberate divergence from the apply path);
- ``apply=true`` requires the exact resolved task, the reconciler role, the
comment permission, a validated repository binding and matching dry-run
evidence;
- live/non-moot/superseded/mismatched/malformed leases fail closed;
- the dry-run ledger is append-only and cleanup stays idempotent.
Every fixture is synthetic. No production PR, lease session or marker is used
anywhere in this module (see ``TestNoProductionLeaseTouched``).
"""
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import os # noqa: E402
import unittest # noqa: E402
from datetime import datetime, timezone # noqa: E402
from unittest.mock import patch # noqa: E402
import mcp_server # noqa: E402
import post_merge_moot_lease_gate as gate # noqa: E402
import reviewer_pr_lease as leases # noqa: E402
from mcp_server import gitea_cleanup_post_merge_moot_lease # noqa: E402
from role_session_router import RECONCILER_TASKS, TASK_REQUIRED_ROLE # noqa: E402
from task_capability_map import required_permission, required_role # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
PR = 487
ISSUE = 485
SESSION = "97274-676d20a825c4"
HEAD_A = "a" * 40
HEAD_B = "d" * 40
LEASE_COMMENT_ID = 6603
CANONICAL_TASK = "cleanup_post_merge_moot_lease"
TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease"
_BASE_OPS = "gitea.read,gitea.pr.comment"
RECONCILER_ENV = {
"GITEA_PROFILE_NAME": "prgs-reconciler",
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.close,gitea.branch.delete",
}
AUTHOR_ENV = {
"GITEA_PROFILE_NAME": "prgs-author",
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.create,gitea.issue.create",
}
REVIEWER_ENV = {
"GITEA_PROFILE_NAME": "prgs-reviewer",
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.review,gitea.pr.approve",
}
MERGER_ENV = {
"GITEA_PROFILE_NAME": "prgs-merger",
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.merge",
}
# Permission shape of the configured role profiles (mirrors
# tests/test_task_capability_role_invariants.py CANONICAL_ROLE_PROFILES).
ROLE_PROFILE_PERMISSIONS = {
"author": {"gitea.read", "gitea.pr.comment", "gitea.pr.create",
"gitea.issue.create", "gitea.issue.comment", "gitea.issue.close",
"gitea.branch.create", "gitea.branch.push", "gitea.repo.commit"},
"reviewer": {"gitea.read", "gitea.pr.comment", "gitea.pr.review",
"gitea.pr.approve", "gitea.pr.request_changes",
"gitea.issue.comment"},
"merger": {"gitea.read", "gitea.pr.comment", "gitea.pr.merge",
"gitea.issue.comment"},
"reconciler": {"gitea.read", "gitea.pr.comment", "gitea.pr.close",
"gitea.issue.close", "gitea.branch.delete"},
}
def _lease_comment(pr_number=PR, session_id=SESSION, *, phase="claimed",
candidate_head=HEAD_A, comment_id=LEASE_COMMENT_ID):
body = leases.format_lease_body(
repo=SLUG,
pr_number=pr_number,
issue_number=ISSUE,
reviewer_identity="sysadmin",
profile="prgs-reviewer",
session_id=session_id,
worktree="branches/review-pr487",
phase=phase,
candidate_head=candidate_head,
target_branch="master",
target_branch_sha="b" * 40,
last_activity=datetime.now(timezone.utc),
)
return {"id": comment_id, "body": body, "user": {"login": "sysadmin"}}
def _api_side_effect(*, pr_state, pr_merged, comments, posted_id=9999):
"""api_request side effect keyed on method + url; records POSTs."""
calls = {"post": []}
def _side(method, url, auth=None, payload=None, *a, **k):
if (method or "").upper() == "POST":
calls["post"].append({"url": url, "payload": payload})
return {"id": posted_id}
if "/comments" in url:
return list(comments)
if "/pulls/" in url:
pr = {"state": pr_state, "number": PR, "merge_commit_sha": "c" * 40}
if pr_merged:
pr["merged"] = True
pr["merged_at"] = "2026-07-08T07:46:04Z"
return pr
if "/issues/" in url:
return {"state": "closed" if pr_merged else "open", "number": ISSUE}
return {}
return _side, calls
def _assessment(comments, *, pr_merged=True, pr_state="closed"):
return leases.assess_post_merge_moot_lease(
comments, pr_number=PR, pr_merged=pr_merged, pr_state=pr_state,
merge_commit_sha="c" * 40,
)
# --------------------------------------------------------------------------- #
# 1-5. Capability map / router contract
# --------------------------------------------------------------------------- #
class TestCleanupTaskContract(unittest.TestCase):
def test_canonical_task_is_reconciler_owned(self):
"""1. The reconciler is the role that can resolve the cleanup task."""
self.assertEqual(required_permission(CANONICAL_TASK), "gitea.pr.comment")
self.assertEqual(required_role(CANONICAL_TASK), "reconciler")
def test_tool_alias_resolves_to_identical_contract(self):
"""5. Alias and canonical task must not diverge."""
self.assertEqual(
(required_permission(TOOL_ALIAS), required_role(TOOL_ALIAS)),
(required_permission(CANONICAL_TASK), required_role(CANONICAL_TASK)),
)
def test_author_reviewer_merger_cannot_resolve_the_task(self):
"""2-4. No non-reconciler role satisfies permission AND role."""
for role in ("author", "reviewer", "merger"):
with self.subTest(role=role):
self.assertNotEqual(required_role(CANONICAL_TASK), role)
# They hold the permission — which is exactly why the role gate
# is required rather than optional.
self.assertIn(
"gitea.pr.comment", ROLE_PROFILE_PERMISSIONS[role],
"test premise: non-reconciler roles do hold pr.comment",
)
def test_reconciler_profile_satisfies_permission_and_role(self):
self.assertIn(
required_permission(CANONICAL_TASK),
ROLE_PROFILE_PERMISSIONS[required_role(CANONICAL_TASK)],
)
def test_router_agrees_with_capability_map(self):
for task in (CANONICAL_TASK, TOOL_ALIAS):
with self.subTest(task=task):
self.assertIn(task, RECONCILER_TASKS)
self.assertEqual(TASK_REQUIRED_ROLE[task], required_role(task))
def test_unknown_alias_still_rejected(self):
for bogus in ("cleanup_post_merge_moot_leases", "cleanup_moot_lease", ""):
with self.subTest(task=bogus):
with self.assertRaises(KeyError):
required_role(bogus)
with self.assertRaises(KeyError):
required_permission(bogus)
# --------------------------------------------------------------------------- #
# Authorization gate unit tests
# --------------------------------------------------------------------------- #
class TestApplyAuthorizationGate(unittest.TestCase):
def setUp(self):
gate._reset_for_testing()
self.addCleanup(gate._reset_for_testing)
self.assessment = _assessment([_lease_comment()])
def _evidence(self, **over):
base = dict(
pr_number=PR, repository_slug=SLUG, lease_moot=True,
cleanup_allowed=True, session_id=SESSION, candidate_head=HEAD_A,
lease_comment_id=LEASE_COMMENT_ID,
)
base.update(over)
return gate.record_dry_run(**base)
def _assess(self, **over):
kwargs = dict(
pr_number=PR, repository_slug=SLUG, resolved_task=CANONICAL_TASK,
active_role_kind="reconciler", assessment=self.assessment,
evidence=self._evidence(),
)
kwargs.update(over)
return gate.assess_apply_authorization(**kwargs)
def test_reconciler_with_matching_evidence_is_authorized(self):
result = self._assess()
self.assertTrue(result["allowed"], result["reasons"])
self.assertTrue(result["evidence_matched"])
def test_apply_without_exact_task_resolution_fails(self):
"""8. Apply without exact task resolution fails preflight."""
result = self._assess(resolved_task=None)
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
def test_resolving_another_task_does_not_authorize_cleanup(self):
"""9. A sibling reconciler task is not a substitute."""
for other in ("delete_branch", "reconcile_already_landed_pr",
"cleanup_merged_pr_branch", "comment_pr"):
with self.subTest(task=other):
result = self._assess(resolved_task=other)
self.assertFalse(result["allowed"])
self.assertEqual(
result["blocker_kind"], "unresolved_cleanup_task")
def test_non_reconciler_roles_fail_closed(self):
"""10. Author/reviewer/merger cannot apply despite pr.comment."""
for role in ("author", "reviewer", "merger", None, ""):
with self.subTest(role=role):
result = self._assess(active_role_kind=role)
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "wrong_role")
def test_missing_repository_identity_fails_closed(self):
result = self._assess(repository_slug=None)
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "repository_binding")
def test_missing_dry_run_evidence_fails_closed(self):
result = self._assess(evidence=None)
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "missing_dry_run_evidence")
def test_dry_run_that_disallowed_cleanup_fails_closed(self):
result = self._assess(evidence=self._evidence(cleanup_allowed=False))
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "dry_run_not_allowed")
def test_dry_run_for_another_pr_or_repo_fails_closed(self):
"""11. Wrong PR / repository fails closed."""
for over in ({"pr_number": PR + 1}, {"repository_slug": "Other/Repo"}):
with self.subTest(**over):
result = self._assess(evidence=self._evidence(**over))
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "dry_run_mismatch")
def test_superseded_lease_fails_closed(self):
"""12. Head/session/marker drift since the dry run fails closed."""
for over in ({"session_id": "other-session"},
{"candidate_head": HEAD_B},
{"lease_comment_id": 7777}):
with self.subTest(**over):
result = self._assess(evidence=self._evidence(**over))
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "superseded_lease")
def test_expectation_mismatch_fails_closed(self):
"""11. Wrong session / head / marker expectations fail closed."""
for over in ({"expected_session_id": "nope"},
{"expected_candidate_head": HEAD_B},
{"expected_lease_comment_id": 7777}):
with self.subTest(**over):
result = self._assess(**over)
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "lease_mismatch")
def test_matching_expectations_are_authorized(self):
result = self._assess(
expected_session_id=SESSION,
expected_candidate_head=HEAD_A,
expected_lease_comment_id=LEASE_COMMENT_ID,
)
self.assertTrue(result["allowed"], result["reasons"])
def test_non_moot_lease_fails_closed(self):
"""12. A live lease on an open PR is never cleanable."""
open_pr = _assessment(
[_lease_comment()], pr_merged=False, pr_state="open")
result = self._assess(assessment=open_pr)
self.assertFalse(result["allowed"])
self.assertIn(
result["blocker_kind"], ("lease_not_moot", "malformed_lease"))
def test_malformed_lease_fails_closed(self):
malformed = dict(self.assessment)
malformed["active_lease"] = {
"session_id": "", "candidate_head": None, "comment_id": None}
result = self._assess(assessment=malformed)
self.assertFalse(result["allowed"])
self.assertEqual(result["blocker_kind"], "malformed_lease")
def test_ledger_is_append_only(self):
"""14. Recording never rewrites or drops prior entries."""
first = self._evidence()
second = self._evidence(candidate_head=HEAD_B, lease_comment_id=7777)
history = gate.dry_run_history()
self.assertEqual(len(history), 2)
self.assertEqual(history[0]["candidate_head"], first["candidate_head"])
self.assertEqual(history[1]["candidate_head"], HEAD_B)
# Newest-wins for lookup, but the older entry survives in history.
latest = gate.latest_dry_run(pr_number=PR, repository_slug=SLUG)
self.assertEqual(latest["lease_comment_id"], second["lease_comment_id"])
self.assertEqual(gate.dry_run_history()[0]["lease_comment_id"],
LEASE_COMMENT_ID)
def test_history_view_cannot_mutate_the_ledger(self):
self._evidence()
snapshot = gate.dry_run_history()
snapshot[0]["pr_number"] = 999999
self.assertEqual(
gate.dry_run_history()[0]["pr_number"], PR,
"dry_run_history must hand out copies, not live rows")
# --------------------------------------------------------------------------- #
# Tool-level behavior
# --------------------------------------------------------------------------- #
class _ToolCase(unittest.TestCase):
def setUp(self):
leases.clear_session_lease()
gate._reset_for_testing()
self.addCleanup(gate._reset_for_testing)
def _run(self, env, *, apply, comments, pr_state="closed", pr_merged=True,
resolved_task=CANONICAL_TASK, slug=SLUG, **kwargs):
side, calls = _api_side_effect(
pr_state=pr_state, pr_merged=pr_merged, comments=comments)
with patch("mcp_server.api_request", side_effect=side), \
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
patch("mcp_server.verify_preflight_purity", return_value=None), \
patch("mcp_server._bound_repository_slug", return_value=slug), \
patch("mcp_server._repository_binding_block", return_value=None), \
patch.object(mcp_server, "_preflight_resolved_task",
resolved_task), \
patch.dict(os.environ, env, clear=True):
result = gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=apply, remote="prgs", **kwargs)
return result, calls
class TestDryRunOpenToEveryRole(_ToolCase):
"""7. Dry run performs no mutation and stays under the read capability."""
def test_dry_run_reports_moot_and_mutates_nothing_for_every_role(self):
for name, env in (("reconciler", RECONCILER_ENV), ("author", AUTHOR_ENV),
("reviewer", REVIEWER_ENV), ("merger", MERGER_ENV)):
with self.subTest(role=name):
gate._reset_for_testing()
result, calls = self._run(
env, apply=False, comments=[_lease_comment()],
resolved_task=None)
self.assertTrue(result["success"])
self.assertTrue(result["lease_moot"])
self.assertTrue(result["cleanup_allowed"])
self.assertFalse(result["cleanup_performed"])
self.assertEqual(result["mode"], "read_only")
self.assertEqual(calls["post"], [], "dry run must not mutate")
def test_dry_run_records_evidence(self):
result, _ = self._run(
RECONCILER_ENV, apply=False, comments=[_lease_comment()])
evidence = result["dry_run_evidence"]
self.assertEqual(evidence["pr_number"], PR)
self.assertEqual(evidence["repository_slug"], SLUG)
self.assertEqual(evidence["session_id"], SESSION)
self.assertEqual(evidence["candidate_head"], HEAD_A)
self.assertEqual(evidence["lease_comment_id"], LEASE_COMMENT_ID)
self.assertTrue(evidence["lease_moot"])
self.assertTrue(evidence["cleanup_allowed"])
class TestApplyRequiresReconciler(_ToolCase):
def test_reconciler_apply_succeeds_after_matching_dry_run(self):
"""7 (apply). Allowed dry run then apply posts exactly one marker."""
comments = [_lease_comment()]
dry, dry_calls = self._run(
RECONCILER_ENV, apply=False, comments=comments)
self.assertTrue(dry["cleanup_allowed"])
self.assertEqual(dry_calls["post"], [])
result, calls = self._run(
RECONCILER_ENV, apply=True, comments=comments)
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["cleanup_performed"])
self.assertEqual(result["released_comment_id"], 9999)
self.assertEqual(len(calls["post"]), 1)
body = calls["post"][0]["payload"]["body"]
self.assertIn("phase: released", body)
self.assertIn("post-merge-moot", body)
def test_apply_without_dry_run_fails_closed(self):
"""6. Apply must be preceded by a matching dry run."""
result, calls = self._run(
RECONCILER_ENV, apply=True, comments=[_lease_comment()])
self.assertFalse(result["success"])
self.assertFalse(result["cleanup_performed"])
self.assertEqual(result["blocker_kind"], "missing_dry_run_evidence")
self.assertEqual(calls["post"], [])
def test_apply_without_exact_task_resolution_fails_closed(self):
"""8. No resolved cleanup task -> no mutation."""
comments = [_lease_comment()]
self._run(RECONCILER_ENV, apply=False, comments=comments)
result, calls = self._run(
RECONCILER_ENV, apply=True, comments=comments, resolved_task=None)
self.assertFalse(result["success"])
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
self.assertEqual(calls["post"], [])
def test_resolving_a_different_task_does_not_authorize_apply(self):
"""9. Another resolved task is not a substitute."""
comments = [_lease_comment()]
self._run(RECONCILER_ENV, apply=False, comments=comments)
result, calls = self._run(
RECONCILER_ENV, apply=True, comments=comments,
resolved_task="delete_branch")
self.assertFalse(result["success"])
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
self.assertEqual(calls["post"], [])
def test_author_reviewer_merger_cannot_apply(self):
"""10. Permission-only roles are refused at the role gate."""
for name, env in (("author", AUTHOR_ENV), ("reviewer", REVIEWER_ENV),
("merger", MERGER_ENV)):
with self.subTest(role=name):
gate._reset_for_testing()
comments = [_lease_comment()]
self._run(env, apply=False, comments=comments)
result, calls = self._run(env, apply=True, comments=comments)
self.assertFalse(result["success"])
self.assertFalse(result["cleanup_performed"])
self.assertEqual(result["blocker_kind"], "wrong_role")
self.assertEqual(
calls["post"], [],
f"{name} must not post a terminal lease marker")
class TestApplyFailsClosedOnLeaseState(_ToolCase):
def test_open_pr_lease_is_never_force_cleaned(self):
"""12. Non-moot: an active lease on an open PR stays untouched."""
comments = [_lease_comment()]
result, calls = self._run(
RECONCILER_ENV, apply=True, comments=comments,
pr_state="open", pr_merged=False)
self.assertFalse(result["cleanup_performed"])
self.assertFalse(result["pr_merged_or_closed"])
self.assertEqual(calls["post"], [], "never force-clean an open PR lease")
self.assertTrue(any(
"still open" in r for r in result.get("cleanup_skipped_reason", [])))
def test_superseded_lease_between_dry_run_and_apply_fails_closed(self):
"""12. The lease moved on after the dry run -> refuse."""
self._run(RECONCILER_ENV, apply=False, comments=[_lease_comment()])
moved = [_lease_comment(session_id="fresh-session",
candidate_head=HEAD_B, comment_id=7777)]
result, calls = self._run(RECONCILER_ENV, apply=True, comments=moved)
self.assertFalse(result["success"])
self.assertEqual(result["blocker_kind"], "superseded_lease")
self.assertEqual(calls["post"], [])
def test_expectation_mismatch_fails_closed(self):
"""11. Wrong session / head / marker expectations refuse the apply."""
comments = [_lease_comment()]
for kwargs in ({"expected_session_id": "wrong-session"},
{"expected_candidate_head": HEAD_B},
{"expected_lease_comment_id": 7777}):
with self.subTest(**kwargs):
gate._reset_for_testing()
self._run(RECONCILER_ENV, apply=False, comments=comments)
result, calls = self._run(
RECONCILER_ENV, apply=True, comments=comments, **kwargs)
self.assertFalse(result["success"])
self.assertEqual(result["blocker_kind"], "lease_mismatch")
self.assertEqual(calls["post"], [])
def test_already_terminal_cleanup_is_idempotent(self):
"""13. A released lease reports nothing to clean and posts nothing."""
first = _assessment([_lease_comment()])
released = {"id": 7000, "body": first["release_body"],
"user": {"login": "sysadmin"}}
comments = [_lease_comment(), released]
self._run(RECONCILER_ENV, apply=False, comments=comments)
result, calls = self._run(RECONCILER_ENV, apply=True, comments=comments)
self.assertFalse(result["cleanup_performed"])
self.assertFalse(result["lease_moot"])
self.assertEqual(calls["post"], [], "no second terminal marker")
self.assertTrue(any(
"already released/terminal" in r
for r in result.get("cleanup_skipped_reason", [])))
def test_apply_is_append_only_never_deletes(self):
"""14. The only write is a POST; nothing is edited or deleted."""
comments = [_lease_comment()]
self._run(RECONCILER_ENV, apply=False, comments=comments)
side, _calls = _api_side_effect(
pr_state="closed", pr_merged=True, comments=comments)
seen = []
def _recording(method, url, auth=None, payload=None, *a, **k):
seen.append((method or "").upper())
return side(method, url, auth, payload, *a, **k)
with patch("mcp_server.api_request", side_effect=_recording), \
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
patch("mcp_server.verify_preflight_purity", return_value=None), \
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
patch("mcp_server._repository_binding_block", return_value=None), \
patch.object(mcp_server, "_preflight_resolved_task",
CANONICAL_TASK), \
patch.dict(os.environ, RECONCILER_ENV, clear=True):
result = gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=True, remote="prgs")
self.assertTrue(result["cleanup_performed"])
self.assertNotIn("DELETE", seen)
self.assertNotIn("PATCH", seen)
self.assertNotIn("PUT", seen)
self.assertEqual(seen.count("POST"), 1)
class TestRepositoryBinding(_ToolCase):
"""11. Foreign-repository targets fail closed before any mutation."""
def test_explicit_foreign_repository_is_rejected(self):
comments = [_lease_comment()]
side, calls = _api_side_effect(
pr_state="closed", pr_merged=True, comments=comments)
with patch("mcp_server.api_request", side_effect=side), \
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
patch("mcp_server.verify_preflight_purity", return_value=None), \
patch("mcp_server._canonical_repository_slug",
return_value=(None, [])), \
patch("mcp_server._workspace_repository_slug",
return_value=SLUG), \
patch.object(mcp_server, "_preflight_resolved_task",
CANONICAL_TASK), \
patch.dict(os.environ, RECONCILER_ENV, clear=True):
gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=False, remote="prgs")
result = gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=True, remote="prgs",
org="Some-Other-Org", repo="Some-Other-Repo")
self.assertFalse(result["success"])
self.assertEqual(result["blocker_kind"], "repository_binding")
self.assertEqual(calls["post"], [])
def test_unresolvable_canonical_root_fails_closed(self):
comments = [_lease_comment()]
side, calls = _api_side_effect(
pr_state="closed", pr_merged=True, comments=comments)
with patch("mcp_server.api_request", side_effect=side), \
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
patch("mcp_server.verify_preflight_purity", return_value=None), \
patch("mcp_server._canonical_repository_slug",
return_value=(None, ["canonical root unresolvable"])), \
patch.object(mcp_server, "_preflight_resolved_task",
CANONICAL_TASK), \
patch.dict(os.environ, RECONCILER_ENV, clear=True):
gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=False, remote="prgs")
result = gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=True, remote="prgs")
self.assertFalse(result["success"])
self.assertEqual(result["blocker_kind"], "repository_binding")
self.assertEqual(calls["post"], [])
class TestPreflightBinding(_ToolCase):
"""4. The apply path binds the shared preflight to the exact task."""
def test_apply_forwards_task_and_target_to_preflight(self):
comments = [_lease_comment()]
self._run(RECONCILER_ENV, apply=False, comments=comments)
side, _calls = _api_side_effect(
pr_state="closed", pr_merged=True, comments=comments)
seen = {}
def _purity(remote=None, worktree_path=None, task=None, **kw):
seen.update({"remote": remote, "worktree_path": worktree_path,
"task": task, **kw})
return None
with patch("mcp_server.api_request", side_effect=side), \
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
patch("mcp_server.verify_preflight_purity", _purity), \
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
patch("mcp_server._repository_binding_block", return_value=None), \
patch.object(mcp_server, "_preflight_resolved_task",
CANONICAL_TASK), \
patch.dict(os.environ, RECONCILER_ENV, clear=True):
result = gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=True, remote="prgs",
worktree_path="/tmp/branches/reconciler-745",
org="Scaled-Tech-Consulting", repo="Gitea-Tools")
self.assertTrue(result["cleanup_performed"])
self.assertEqual(seen["task"], CANONICAL_TASK)
self.assertEqual(seen["worktree_path"], "/tmp/branches/reconciler-745")
self.assertEqual(seen["org"], "Scaled-Tech-Consulting")
self.assertEqual(seen["repo"], "Gitea-Tools")
def test_dry_run_does_not_require_preflight(self):
def _boom(*a, **k):
raise AssertionError("dry run must not run mutation preflight")
side, calls = _api_side_effect(
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
with patch("mcp_server.api_request", side_effect=side), \
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
patch("mcp_server.verify_preflight_purity", _boom), \
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=False, remote="prgs")
self.assertTrue(result["success"])
self.assertEqual(calls["post"], [])
class TestNoProductionLeaseTouched(unittest.TestCase):
"""16. No real production lease or PR is referenced by these tests."""
PRODUCTION_PR = 744
PRODUCTION_SESSION = "33673-1d54887a0415"
PRODUCTION_MARKER = 12452
def test_fixtures_are_synthetic(self):
self.assertNotEqual(PR, self.PRODUCTION_PR)
self.assertNotEqual(SESSION, self.PRODUCTION_SESSION)
self.assertNotEqual(LEASE_COMMENT_ID, self.PRODUCTION_MARKER)
def test_module_source_never_names_the_production_lease(self):
source = _Path(__file__).read_text()
for token in (self.PRODUCTION_SESSION, str(self.PRODUCTION_MARKER)):
self.assertEqual(
source.count(token), 1,
f"{token!r} must appear only in this guard's own constants",
)
if __name__ == "__main__":
unittest.main()
+210
View File
@@ -0,0 +1,210 @@
"""Tests for the 10-minute sliding TTL on reviewer and merger PR leases (#747).
The lease ledger previously minted a fixed 120-minute expiry and derived
staleness from separate 30/60-minute activity bands. A dead session therefore
held a PR for up to two hours. These tests pin the sliding-window contract:
acquisition mints a 10-minute expiry, every heartbeat slides it forward, and an
expired lease is immediately reclaimable with no intermediate waiting tier.
"""
import sys
import unittest
from datetime import datetime, timedelta, timezone
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import reviewer_pr_lease as leases
def _body(
*,
session_id: str = "session-a",
pr_number: int = 747,
phase: str = "claimed",
last_activity: datetime | None = None,
expires_at: datetime | None = None,
ttl_minutes: int | None = None,
) -> str:
kwargs = {}
if ttl_minutes is not None:
kwargs["ttl_minutes"] = ttl_minutes
return leases.format_lease_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=pr_number,
issue_number=747,
reviewer_identity="rev1",
profile="prgs-reviewer",
session_id=session_id,
worktree="branches/review-pr747",
phase=phase,
candidate_head="a" * 40,
target_branch="master",
target_branch_sha="b" * 40,
last_activity=last_activity,
expires_at=expires_at,
**kwargs,
)
def _comment(**kwargs) -> dict:
return {"id": 1, "body": _body(**kwargs), "user": {"login": "rev1"}}
def _minutes_ago(minutes: int) -> datetime:
return datetime.now(timezone.utc) - timedelta(minutes=minutes)
class TestSlidingTTLConstant(unittest.TestCase):
"""AC6: one named constant per lease kind, no duplicated literals."""
def test_ttl_is_ten_minutes(self):
self.assertEqual(leases.LEASE_TTL_MINUTES, 10)
def test_renewal_window_is_separately_named(self):
self.assertEqual(leases.LEASE_RENEWAL_MINUTES, 10)
class TestAcquisitionTTL(unittest.TestCase):
"""AC1 / AC2: reviewer and merger acquisition both mint now + 10 minutes."""
def test_acquire_mints_ten_minute_expiry(self):
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
lease = leases.parse_lease_comment(_body(last_activity=now))
expires = leases._parse_timestamp(lease["expires_at"])
self.assertEqual(expires, now + timedelta(minutes=10))
def test_merger_acquisition_shares_the_same_window(self):
# Merger acquisition funnels through the same lease-body formatter, so
# the reviewer TTL is the merger TTL by construction.
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
lease = leases.parse_lease_comment(_body(phase="merging", last_activity=now))
expires = leases._parse_timestamp(lease["expires_at"])
self.assertEqual(expires, now + timedelta(minutes=10))
class TestHeartbeatSlides(unittest.TestCase):
"""AC3: a heartbeat slides expires_at to now + 10 minutes."""
def test_heartbeat_slides_expiry_forward(self):
acquired = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
beat = acquired + timedelta(minutes=7)
first = leases.parse_lease_comment(_body(last_activity=acquired))
renewed = leases.parse_lease_comment(_body(last_activity=beat))
first_expiry = leases._parse_timestamp(first["expires_at"])
renewed_expiry = leases._parse_timestamp(renewed["expires_at"])
self.assertEqual(renewed_expiry, beat + timedelta(minutes=10))
self.assertGreater(renewed_expiry, first_expiry)
def test_renewal_window_is_independently_tunable(self):
# The renewal amount must not be hardwired to the acquisition TTL;
# format_lease_body accepts an explicit window.
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
lease = leases.parse_lease_comment(_body(last_activity=now, ttl_minutes=3))
expires = leases._parse_timestamp(lease["expires_at"])
self.assertEqual(expires, now + timedelta(minutes=3))
class TestFreshnessBands(unittest.TestCase):
"""AC5: expiry is the only gate; no intermediate reclaim tier."""
def test_fresh_lease_is_active(self):
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(1)))
self.assertEqual(leases.classify_lease_freshness(lease), "active")
def test_idle_past_half_ttl_warns_before_expiry(self):
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(6)))
self.assertEqual(leases.classify_lease_freshness(lease), "stale_warning")
def test_lease_expires_after_ten_idle_minutes(self):
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(11)))
self.assertEqual(leases.classify_lease_freshness(lease), "expired")
def test_no_separate_reclaimable_tier_remains(self):
# The old 60-minute reclaim band sat between "stale" and "expired" and
# blocked acquisition. Under a sliding TTL an idle lease is already
# expired, so the tier must not reappear at any idle duration.
for minutes in (11, 30, 65, 121, 600):
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(minutes)))
self.assertEqual(
leases.classify_lease_freshness(lease),
"expired",
f"idle {minutes}m should be expired, not a waiting tier",
)
class TestExpiredLeaseIsImmediatelyReclaimable(unittest.TestCase):
"""AC5: another session takes over an expired lease with no extra wait."""
def setUp(self):
leases.clear_session_lease()
def _acquire_against(self, comments: list[dict]) -> dict:
return leases.assess_acquire_lease(
comments,
pr_number=747,
reviewer_identity="rev2",
profile="prgs-reviewer",
session_id="session-b",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=747,
worktree="branches/review-pr747-b",
candidate_head="c" * 40,
target_branch="master",
target_branch_sha="d" * 40,
)
def test_expired_foreign_lease_does_not_block_acquisition(self):
comments = [_comment(session_id="dead-session", last_activity=_minutes_ago(11))]
result = self._acquire_against(comments)
self.assertTrue(result["acquire_allowed"], result["reasons"])
def test_live_foreign_lease_still_blocks_acquisition(self):
comments = [_comment(session_id="live-session", last_activity=_minutes_ago(2))]
result = self._acquire_against(comments)
self.assertFalse(result["acquire_allowed"])
self.assertTrue(any("already has active" in r for r in result["reasons"]))
class TestRemainingTimeReporting(unittest.TestCase):
"""AC7: diagnostics can distinguish 'held and live' from 'held and dying'."""
def test_seconds_remaining_on_live_lease(self):
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
lease = leases.parse_lease_comment(_body(last_activity=now))
remaining = leases.lease_seconds_remaining(lease, now=now + timedelta(minutes=4))
self.assertEqual(remaining, 360)
def test_seconds_remaining_is_zero_when_expired(self):
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
lease = leases.parse_lease_comment(_body(last_activity=now))
remaining = leases.lease_seconds_remaining(lease, now=now + timedelta(minutes=30))
self.assertEqual(remaining, 0)
def test_seconds_remaining_is_none_without_parsable_expiry(self):
self.assertIsNone(leases.lease_seconds_remaining({"expires_at": "not-a-time"}))
class TestLegacyLeaseRows(unittest.TestCase):
"""AC9: leases minted under the old 120-minute TTL still evaluate."""
def test_legacy_two_hour_expiry_is_honoured_until_it_passes(self):
now = datetime.now(timezone.utc)
legacy = leases.parse_lease_comment(
_body(last_activity=now - timedelta(minutes=90), expires_at=now + timedelta(minutes=30))
)
# Still inside its originally minted window: not expired, but idle long
# enough to warn.
self.assertEqual(leases.classify_lease_freshness(legacy), "stale_warning")
def test_legacy_row_past_its_own_expiry_is_expired(self):
now = datetime.now(timezone.utc)
legacy = leases.parse_lease_comment(
_body(last_activity=now - timedelta(minutes=180), expires_at=now - timedelta(minutes=60))
)
self.assertEqual(leases.classify_lease_freshness(legacy), "expired")
if __name__ == "__main__":
unittest.main()
+602
View File
@@ -0,0 +1,602 @@
"""Regression coverage for the PR checks assessor defect (#751).
Gitea's *combined* commit status reports ``state: pending`` both when a check is
executing and when the status-context collection is empty. The assessor read
``state`` alone and defaulted ``checks_required`` to ``True``, so a PR whose head
had no status contexts and never would was routed to ``blocked`` forever.
These tests pin the corrected semantics end to end: live branch protection
decides whether checks are required, and the actual context collection decides
what the checks say.
"""
from __future__ import annotations
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import pr_sync_status # noqa: E402
from pr_sync_status import ( # noqa: E402
ACTION_BLOCKED,
ACTION_MERGE_NOW,
ACTION_UPDATE_BRANCH_BY_MERGE,
CHECKS_FAILURE,
CHECKS_MISSING_REQUIRED,
CHECKS_NONE,
CHECKS_NOT_REQUIRED,
CHECKS_PENDING,
CHECKS_SUCCESS,
CHECKS_UNKNOWN,
assess_pr_sync_status,
classify_commit_checks,
)
def _sha(prefix: str) -> str:
return (prefix + "0" * 40)[:40]
PR_HEAD = _sha("aaaaaaaa")
BASE_HEAD = _sha("bbbbbbbb")
def _base_kwargs(**overrides):
data = {
"host": "gitea.prgs.cc",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"pr_number": 751,
"pr_state": "open",
"source_branch": "fix/issue-751-checks-assessor",
"pr_head_sha": PR_HEAD,
"base_head_sha": BASE_HEAD,
"commits_behind": 0,
"mergeable": True,
"has_conflicts": False,
"branch_protection_requires_current_base": False,
"approval_at_current_head": True,
"checks_status": "success",
"active_author_lock": True,
"active_reviewer_lease": False,
"active_merger_lease": False,
}
data.update(overrides)
return data
def _reasons(result) -> str:
return " | ".join(result["reasons"]).lower()
class TestClassifyCommitChecks(unittest.TestCase):
"""Pure classification from live evidence."""
def test_empty_collection_with_combined_pending_is_not_executing_ci(self):
# The exact PR #750 failure mode at the classification layer.
result = classify_commit_checks(
combined_state="pending",
statuses=[],
checks_enabled=True,
required_contexts=[],
)
self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
self.assertEqual(result["checks_status"], CHECKS_NONE)
self.assertEqual(result["context_count"], 0)
joined = " ".join(result["reasons"]).lower()
self.assertIn("empty", joined)
self.assertIn("does not indicate", joined)
def test_protection_disables_status_checks(self):
result = classify_commit_checks(
combined_state="pending",
statuses=[],
checks_enabled=False,
required_contexts=[],
)
self.assertFalse(result["checks_required"])
self.assertEqual(result["checks_status"], CHECKS_NOT_REQUIRED)
def test_no_required_contexts_aggregates_reported_contexts(self):
result = classify_commit_checks(
combined_state="success",
statuses=[{"context": "build", "status": "success"}],
checks_enabled=True,
required_contexts=[],
)
self.assertTrue(result["checks_required"])
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
def test_required_checks_pending(self):
result = classify_commit_checks(
combined_state="pending",
statuses=[{"context": "build", "status": "pending"}],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(result["checks_status"], CHECKS_PENDING)
def test_required_checks_failed(self):
result = classify_commit_checks(
combined_state="failure",
statuses=[{"context": "build", "status": "failure"}],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(result["checks_status"], CHECKS_FAILURE)
def test_required_checks_successful(self):
result = classify_commit_checks(
combined_state="success",
statuses=[{"context": "build", "status": "success"}],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
self.assertTrue(result["checks_required"])
def test_required_context_configured_with_no_matching_result(self):
result = classify_commit_checks(
combined_state="success",
statuses=[{"context": "lint", "status": "success"}],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
self.assertEqual(result["missing_required_contexts"], ["build"])
def test_mixed_required_and_unrelated_contexts_ignores_unrelated(self):
# An unrelated failing context must not fail a satisfied required set.
result = classify_commit_checks(
combined_state="failure",
statuses=[
{"context": "build", "status": "success"},
{"context": "optional-scan", "status": "failure"},
],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
# ...and a failing *required* context still fails despite passing extras.
failing = classify_commit_checks(
combined_state="success",
statuses=[
{"context": "build", "status": "failure"},
{"context": "optional-scan", "status": "success"},
],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(failing["checks_status"], CHECKS_FAILURE)
def test_policy_unreadable_fails_closed(self):
result = classify_commit_checks(
combined_state=None,
statuses=[],
checks_enabled=None,
required_contexts=[],
policy_determinable=False,
)
self.assertTrue(result["checks_required"])
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
self.assertIn("fail closed", " ".join(result["reasons"]).lower())
def test_malformed_policy_indeterminate_flag_fails_closed(self):
result = classify_commit_checks(
combined_state="success",
statuses=[{"context": "build", "status": "success"}],
checks_enabled=None,
required_contexts=[],
policy_determinable=True,
)
self.assertTrue(result["checks_required"])
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
def test_status_collection_unreadable_fails_closed_when_required(self):
result = classify_commit_checks(
checks_enabled=True,
required_contexts=["build"],
status_determinable=False,
)
self.assertTrue(result["checks_required"])
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
def test_unrecognized_context_state_never_reads_as_success(self):
result = classify_commit_checks(
combined_state="success",
statuses=[{"context": "build", "status": "banana"}],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
def test_newest_wins_per_context(self):
# Gitea returns newest-first; the stale failure must not win.
result = classify_commit_checks(
combined_state="success",
statuses=[
{"context": "build", "status": "success"},
{"context": "build", "status": "failure"},
],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
def test_malformed_status_rows_are_discarded_not_treated_as_passing(self):
result = classify_commit_checks(
combined_state="pending",
statuses=[{"context": "build"}, "not-a-dict", None],
checks_enabled=True,
required_contexts=["build"],
)
self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
class TestChecksGateSemantics(unittest.TestCase):
"""``assess_pr_sync_status`` routing and blocker reasons."""
def test_not_required_allows_merge_now_with_empty_checks(self):
result = assess_pr_sync_status(
**_base_kwargs(checks_status=CHECKS_NOT_REQUIRED),
checks_required=False,
)
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
self.assertTrue(result["approval_valid_for_merge"])
self.assertFalse(result["checks_required"])
self.assertIn("does not require status checks", _reasons(result))
def test_none_blocks_when_checks_required(self):
result = assess_pr_sync_status(
**_base_kwargs(checks_status=CHECKS_NONE),
checks_required=True,
)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertIn("no status context", _reasons(result))
self.assertIn("not executing ci", _reasons(result))
def test_missing_required_blocks(self):
result = assess_pr_sync_status(
**_base_kwargs(checks_status=CHECKS_MISSING_REQUIRED),
checks_required=True,
)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertIn("no matching status result", _reasons(result))
def test_required_pending_blocks(self):
result = assess_pr_sync_status(
**_base_kwargs(checks_status=CHECKS_PENDING), checks_required=True
)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertIn("not finished", _reasons(result))
def test_required_failure_blocks(self):
result = assess_pr_sync_status(
**_base_kwargs(checks_status=CHECKS_FAILURE), checks_required=True
)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertIn("failed", _reasons(result))
def test_required_success_merges(self):
result = assess_pr_sync_status(
**_base_kwargs(checks_status=CHECKS_SUCCESS), checks_required=True
)
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
def test_unknown_blocks_when_required(self):
result = assess_pr_sync_status(
**_base_kwargs(checks_status=CHECKS_UNKNOWN), checks_required=True
)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertIn("unknown", _reasons(result))
def test_unrecognized_status_does_not_fall_through_to_merge(self):
# Regression: the previous gate only handled a fixed vocabulary and let
# anything else reach merge_now.
result = assess_pr_sync_status(
**_base_kwargs(checks_status="totally-unexpected"),
checks_required=True,
)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertIn("unrecognized checks status", _reasons(result))
def test_checks_gate_does_not_bypass_approval_requirement(self):
result = assess_pr_sync_status(
**_base_kwargs(
checks_status=CHECKS_NOT_REQUIRED, approval_at_current_head=False
),
checks_required=False,
)
self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
self.assertFalse(result["approval_valid_for_merge"])
def test_checks_gate_does_not_bypass_current_base_protection(self):
# Existing current-base behavior stays intact (#727).
result = assess_pr_sync_status(
**_base_kwargs(
checks_status=CHECKS_NOT_REQUIRED,
commits_behind=3,
branch_protection_requires_current_base=True,
),
checks_required=False,
)
self.assertEqual(
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
)
def test_checks_gate_does_not_bypass_conflicts(self):
result = assess_pr_sync_status(
**_base_kwargs(checks_status=CHECKS_NOT_REQUIRED, mergeable=False),
checks_required=False,
)
self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
class TestBranchProtectionPolicy(unittest.TestCase):
"""Live protection reader derives the status-check requirement."""
def setUp(self):
import gitea_mcp_server as gms
self.gms = gms
def _policy(self, responses):
def fake_api_request(method, url, auth, *args, **kwargs):
for fragment, payload in responses.items():
if fragment in url:
if isinstance(payload, Exception):
raise payload
return payload
return None
with patch.object(self.gms, "api_request", side_effect=fake_api_request):
return self.gms._branch_protection_policy(
"https://example/api/v1/repos/o/r", {"h": "1"}, base_branch="master"
)
def test_status_checks_enabled_with_contexts(self):
policy = self._policy({
"branch_protections": [{
"branch_name": "master",
"block_on_outdated_branch": True,
"enable_status_check": True,
"status_check_contexts": ["ci/build", " "],
}],
})
self.assertTrue(policy["determinable"])
self.assertTrue(policy["checks_enabled"])
self.assertTrue(policy["requires_current_base"])
self.assertEqual(policy["required_contexts"], ["ci/build"])
def test_status_checks_disabled(self):
policy = self._policy({
"branch_protections": [{
"branch_name": "master",
"enable_status_check": False,
}],
})
self.assertTrue(policy["determinable"])
self.assertFalse(policy["checks_enabled"])
def test_no_protection_rule_means_checks_not_required(self):
# PR #750's repository shape: protection list readable but empty.
policy = self._policy({"branch_protections": [], "branches/master": {}})
self.assertTrue(policy["determinable"])
self.assertFalse(policy["protection_found"])
self.assertFalse(policy["checks_enabled"])
self.assertIsNone(policy["requires_current_base"])
def test_protection_without_status_field_means_not_enabled(self):
policy = self._policy({
"branch_protections": [{
"branch_name": "master",
"block_on_outdated_branch": True,
}],
})
self.assertTrue(policy["determinable"])
self.assertFalse(policy["checks_enabled"])
self.assertTrue(policy["requires_current_base"])
def test_api_failure_is_not_determinable(self):
policy = self._policy({
"branch_protections": RuntimeError("boom"),
})
self.assertFalse(policy["determinable"])
self.assertIsNone(policy["checks_enabled"])
def test_missing_branch_is_not_determinable(self):
with patch.object(self.gms, "api_request", return_value=[]):
policy = self.gms._branch_protection_policy(
"https://example/api/v1/repos/o/r", {}, base_branch=" "
)
self.assertFalse(policy["determinable"])
def test_requires_current_base_accessor_preserved(self):
def fake(method, url, auth, *a, **k):
if "branch_protections" in url:
return [{"branch_name": "master",
"block_on_outdated_branch": True}]
return None
with patch.object(self.gms, "api_request", side_effect=fake):
value = self.gms._branch_protection_requires_current_base(
"https://example/api/v1/repos/o/r", {}, base_branch="master"
)
self.assertTrue(value)
class TestCommitChecksSnapshot(unittest.TestCase):
def setUp(self):
import gitea_mcp_server as gms
self.gms = gms
def test_reads_state_and_context_collection(self):
payload = {"state": "pending", "statuses": [{"context": "b",
"status": "pending"}]}
with patch.object(self.gms, "api_request", return_value=payload):
snap = self.gms._commit_checks_snapshot(
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
)
self.assertTrue(snap["determinable"])
self.assertEqual(snap["combined_state"], "pending")
self.assertEqual(len(snap["statuses"]), 1)
def test_empty_collection_recorded_as_determinable(self):
with patch.object(
self.gms, "api_request", return_value={"state": "pending", "statuses": []}
):
snap = self.gms._commit_checks_snapshot(
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
)
self.assertTrue(snap["determinable"])
self.assertEqual(snap["statuses"], [])
def test_api_failure_is_not_determinable(self):
with patch.object(self.gms, "api_request", side_effect=RuntimeError("x")):
snap = self.gms._commit_checks_snapshot(
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
)
self.assertFalse(snap["determinable"])
class TestMcpWrapperForwarding(unittest.TestCase):
"""The production MCP path must reach the derived ``checks_required``."""
def setUp(self):
import gitea_mcp_server as gms
self.gms = gms
self.base = (
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools"
)
def _patches(self, fake_api_request, spy):
return [
patch.object(self.gms, "_profile_operation_gate", return_value=None),
patch.object(self.gms, "_resolve", return_value=(
"gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")),
patch.object(self.gms, "_auth", return_value={"Authorization": "x"}),
patch.object(self.gms, "repo_api_url", return_value=self.base),
patch.object(self.gms, "api_request", side_effect=fake_api_request),
patch.object(self.gms, "gitea_get_pr_review_feedback", return_value={
"success": True, "approval_at_current_head": True}),
patch.object(self.gms, "_prove_author_ownership_for_pr", return_value={
"has_author_lock": True}),
patch.object(pr_sync_status, "assess_pr_sync_status", side_effect=spy),
]
def _run(self, *, protections, status_payload, pr_number=750,
caller_checks_status=None):
captured = {}
real_assess = pr_sync_status.assess_pr_sync_status
def spy(**kwargs):
captured.update(kwargs)
return real_assess(**kwargs)
def fake_api_request(method, url, auth, *args, **kwargs):
if f"/pulls/{pr_number}" in url:
return {
"number": pr_number,
"state": "open",
"title": "t",
"body": "b",
"mergeable": True,
"head": {"sha": PR_HEAD, "ref": "fix/x"},
"base": {"sha": BASE_HEAD, "ref": "master"},
}
if "/branch_protections" in url:
if isinstance(protections, Exception):
raise protections
return protections
if "/branches/master" in url:
return {"commit": {"id": BASE_HEAD}}
if "/status" in url:
if isinstance(status_payload, Exception):
raise status_payload
return status_payload
if "/compare/" in url:
return {"total_commits": 0}
if "/comments" in url:
return []
return None
stack = self._patches(fake_api_request, spy)
for p in stack:
p.start()
try:
result = self.gms.gitea_assess_pr_sync_status(
pr_number=pr_number, remote="prgs",
checks_status=caller_checks_status,
)
finally:
for p in reversed(stack):
p.stop()
return result, captured
def test_derived_checks_required_is_forwarded(self):
result, captured = self._run(
protections=[{"branch_name": "master", "enable_status_check": True,
"status_check_contexts": ["ci/build"]}],
status_payload={"state": "pending", "statuses": [
{"context": "ci/build", "status": "pending"}]},
)
self.assertIn("checks_required", captured)
self.assertTrue(captured["checks_required"])
self.assertEqual(captured["checks_status"], CHECKS_PENDING)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
def test_pr750_empty_status_with_no_protection_is_merge_ready(self):
# The exact reported failure: combined pending, zero contexts, and no
# branch protection requiring checks.
result, captured = self._run(
protections=[],
status_payload={"state": "pending", "statuses": []},
)
self.assertFalse(captured["checks_required"])
self.assertEqual(captured["checks_status"], CHECKS_NOT_REQUIRED)
self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
def test_protection_read_failure_blocks(self):
result, captured = self._run(
protections=RuntimeError("protection unavailable"),
status_payload={"state": "success", "statuses": []},
)
self.assertTrue(captured["checks_required"])
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
def test_caller_supplied_status_cannot_mask_live_required_failure(self):
# A caller-declared "success" must not override live evidence.
result, captured = self._run(
protections=[{"branch_name": "master", "enable_status_check": True,
"status_check_contexts": ["ci/build"]}],
status_payload={"state": "failure", "statuses": [
{"context": "ci/build", "status": "failure"}]},
caller_checks_status="success",
)
self.assertEqual(captured["checks_status"], CHECKS_FAILURE)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertEqual(
result["checks_evidence"]["caller_supplied_checks_status"], "success"
)
def test_checks_evidence_is_reported_without_secrets(self):
result, _ = self._run(
protections=[],
status_payload={"state": "pending", "statuses": []},
)
evidence = result["checks_evidence"]
self.assertEqual(evidence["context_count"], 0)
self.assertEqual(evidence["combined_state"], "pending")
self.assertFalse(evidence["protection_found"])
blob = repr(result).lower()
self.assertNotIn("authorization", blob)
self.assertNotIn("token", blob)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,364 @@
"""Dead-session author issue-lock recovery (#753).
Covers the narrow recovery path that lets an author re-acquire a durable lock
after the MCP session that recorded it exits, plus every rejection condition
that must keep failing closed.
"""
import os
import subprocess
import sys
import unittest
from datetime import datetime, timedelta, timezone
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import issue_lock_recovery # noqa: E402
import issue_lock_store # noqa: E402
import issue_lock_worktree # noqa: E402
ISSUE = 4242
BRANCH = f"fix/issue-{ISSUE}-demo"
WORKTREE = "/scratch/wt"
HEAD = "a" * 40
OTHER_SHA = "b" * 40
IDENTITY = "example-user"
PROFILE = "example-author"
def dead_pid() -> int:
"""A PID that has certainly exited (spawned, then reaped)."""
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
return proc.pid
def future_ts(hours: int = 4) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
def make_lock(**overrides):
lock = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": WORKTREE,
"remote": "prgs",
"org": "ExampleOrg",
"repo": "ExampleRepo",
"session_pid": dead_pid(),
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"branch": BRANCH,
"worktree_path": WORKTREE,
"claimant": {"username": IDENTITY, "profile": PROFILE},
"expires_at": future_ts(),
},
}
lock.update(overrides)
return lock
def assess(lock=None, **overrides):
kwargs = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": WORKTREE,
"remote": "prgs",
"org": "ExampleOrg",
"repo": "ExampleRepo",
"identity": IDENTITY,
"profile": PROFILE,
"current_branch": BRANCH,
"porcelain_status": "",
"head_sha": HEAD,
"remote_head_sha": HEAD,
"pr_head_sha": HEAD,
"pr_number": 99,
"competing_live_locks": [],
"candidate_branches": [BRANCH],
"current_pid": os.getpid(),
}
kwargs.update(overrides)
return issue_lock_recovery.assess_dead_session_lock_recovery(
make_lock() if lock is None else lock, **kwargs
)
class TestDeadSessionRecoveryGranted(unittest.TestCase):
def test_dead_pid_with_exact_evidence_recovers(self):
result = assess()
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
self.assertEqual(result["outcome"], issue_lock_recovery.RECOVERY_SANCTIONED)
def test_recovery_still_granted_when_no_open_pr_exists(self):
# A locked branch need not have a PR yet; absence must not block.
result = assess(pr_head_sha=None, pr_number=None)
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
def test_lease_expiry_is_not_required_for_recovery(self):
# The defining condition is PID death, not TTL expiry (the #601 gap).
lock = make_lock()
self.assertFalse(issue_lock_store.is_lease_expired(lock))
self.assertFalse(issue_lock_store.assess_lock_freshness(lock)["live"])
self.assertTrue(assess(lock)["recovery_sanctioned"])
class TestDeadSessionRecoveryRefused(unittest.TestCase):
def assert_refused(self, result, needle):
self.assertFalse(result["recovery_sanctioned"])
self.assertEqual(result["outcome"], issue_lock_recovery.REFUSED)
self.assertTrue(
any(needle in reason for reason in result["reasons"]),
f"expected {needle!r} in {result['reasons']}",
)
def test_live_prior_pid_refused(self):
lock = make_lock(session_pid=os.getpid(), pid=os.getpid())
# Distinct current pid so the refusal is attributable to liveness.
self.assert_refused(assess(lock, current_pid=os.getpid() + 1), "still alive")
def test_different_author_identity_refused(self):
self.assert_refused(
assess(identity="someone-else"), "does not match active identity"
)
def test_different_profile_refused(self):
self.assert_refused(
assess(profile="other-profile"), "does not match active profile"
)
def test_different_branch_refused(self):
lock = make_lock(branch_name=f"fix/issue-{ISSUE}-other")
self.assert_refused(assess(lock), "does not match requested")
def test_worktree_parked_on_another_branch_refused(self):
self.assert_refused(assess(current_branch="master"), "not the locked branch")
def test_detached_head_worktree_refused(self):
self.assert_refused(assess(current_branch=None), "detached HEAD")
def test_different_worktree_refused(self):
self.assert_refused(
assess(worktree_path="/scratch/elsewhere"), "does not match declared"
)
def test_dirty_worktree_refused(self):
self.assert_refused(
assess(porcelain_status=" M gitea_mcp_server.py\n"), "requires a clean"
)
def test_local_head_differing_from_remote_refused(self):
self.assert_refused(
assess(remote_head_sha=OTHER_SHA), "does not match remote branch head"
)
def test_pr_head_differing_refused(self):
self.assert_refused(assess(pr_head_sha=OTHER_SHA), "does not match local head")
def test_missing_remote_head_refused(self):
self.assert_refused(assess(remote_head_sha=None), "remote head")
def test_competing_live_lock_refused(self):
competing = [
{
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": "/scratch/other-wt",
"pid": os.getpid(),
}
]
self.assert_refused(
assess(competing_live_locks=competing), "competing live lock"
)
def test_unrelated_live_lock_does_not_block(self):
unrelated = [
{
"issue_number": 999,
"branch_name": "fix/issue-999-unrelated",
"worktree_path": "/scratch/unrelated",
"pid": os.getpid(),
}
]
self.assertTrue(assess(competing_live_locks=unrelated)["recovery_sanctioned"])
def test_multiple_candidate_branches_refused(self):
self.assert_refused(
assess(candidate_branches=[BRANCH, f"feat/issue-{ISSUE}-rival"]),
"multiple branches claim this issue",
)
def test_repository_scope_mismatch_refused(self):
self.assert_refused(assess(repo="OtherRepo"), "does not match requested")
def test_malformed_lock_missing_worktree_refused(self):
lock = make_lock()
lock.pop("worktree_path")
self.assert_refused(assess(lock), "incomplete")
def test_malformed_lock_missing_pid_refused(self):
lock = make_lock()
lock.pop("session_pid", None)
lock.pop("pid", None)
self.assert_refused(assess(lock), "incomplete")
def test_lock_without_claimant_refused(self):
lock = make_lock()
lock["work_lease"] = dict(lock["work_lease"])
lock["work_lease"].pop("claimant")
self.assert_refused(assess(lock), "claimant identity/profile")
class TestNotACandidate(unittest.TestCase):
def test_absent_lock_is_not_a_candidate(self):
result = issue_lock_recovery.assess_dead_session_lock_recovery(
None,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=WORKTREE,
remote="prgs",
org="ExampleOrg",
repo="ExampleRepo",
identity=IDENTITY,
profile=PROFILE,
current_branch=BRANCH,
porcelain_status="",
head_sha=HEAD,
remote_head_sha=HEAD,
)
self.assertEqual(result["outcome"], issue_lock_recovery.NO_CANDIDATE)
self.assertFalse(result["recovery_sanctioned"])
self.assertFalse(result["is_candidate"])
def test_lock_for_a_different_issue_is_not_a_candidate(self):
result = assess(make_lock(issue_number=7777))
self.assertEqual(result["outcome"], issue_lock_recovery.NO_CANDIDATE)
self.assertFalse(result["recovery_sanctioned"])
class TestWorktreeGateWaiver(unittest.TestCase):
def test_new_issue_claim_still_requires_base_equivalence(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=WORKTREE,
current_branch=BRANCH,
porcelain_status="",
base_equivalent=False,
)
self.assertTrue(result["block"])
self.assertFalse(result["base_equivalence_waived"])
def test_sanctioned_recovery_waives_base_equivalence(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=WORKTREE,
current_branch=BRANCH,
porcelain_status="",
base_equivalent=False,
recovery_sanctioned=True,
)
self.assertTrue(result["proven"], result["reasons"])
self.assertTrue(result["base_equivalence_waived"])
def test_recovery_never_waives_cleanliness(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=WORKTREE,
current_branch=BRANCH,
porcelain_status=" M gitea_mcp_server.py\n",
base_equivalent=False,
recovery_sanctioned=True,
)
self.assertTrue(result["block"])
self.assertTrue(
any("tracked file edits" in reason for reason in result["reasons"])
)
def test_unproven_base_equivalence_still_blocks_without_recovery(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=WORKTREE,
current_branch=BRANCH,
porcelain_status="",
base_equivalent=None,
)
self.assertTrue(result["block"])
class TestRecoveryRecordAndDownstream(unittest.TestCase):
def test_recovery_record_preserves_truthful_provenance(self):
assessment = assess()
prior = assessment["evidence"]["prior_session_pid"]
record = issue_lock_recovery.build_recovery_record(
assessment, recovered_at="2026-07-18T23:21:40Z"
)
self.assertTrue(record["recovered"])
self.assertEqual(record["prior_session_pid"], prior)
self.assertEqual(record["replacement_session_pid"], os.getpid())
self.assertNotEqual(
record["prior_session_pid"], record["replacement_session_pid"]
)
self.assertFalse(record["prior_pid_alive"])
self.assertEqual(record["recovered_at"], "2026-07-18T23:21:40Z")
self.assertEqual(record["branch_name"], BRANCH)
self.assertEqual(record["local_head"], HEAD)
self.assertEqual(record["identity"], IDENTITY)
self.assertTrue(record["proof"])
def test_recovery_record_carries_no_secret_material(self):
record = issue_lock_recovery.build_recovery_record(
assess(), recovered_at="2026-07-18T23:21:40Z"
)
blob = repr(record).lower()
for banned in ("token", "password", "authorization", "secret", "api_key"):
self.assertNotIn(banned, blob)
def test_recovered_lock_satisfies_update_by_merge_ownership(self):
# After recovery the lock is rebound to the live session, so the
# ownership re-check used by gitea_update_pr_branch_by_merge passes.
assessment = assess()
recovered_lock = make_lock(session_pid=os.getpid(), pid=os.getpid())
recovered_lock["dead_session_recovery"] = (
issue_lock_recovery.build_recovery_record(
assessment, recovered_at="2026-07-18T23:21:40Z"
)
)
freshness = issue_lock_store.assess_lock_freshness(recovered_lock)
self.assertTrue(freshness["live"], freshness)
verdict = issue_lock_store.verify_lock_for_mutation(
recovered_lock,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=WORKTREE,
)
self.assertTrue(verdict["proven"], verdict["reasons"])
self.assertFalse(verdict["block"])
def test_pre_recovery_lock_fails_ownership_check(self):
# Guards against a false positive above: the dead-PID lock must fail.
verdict = issue_lock_store.verify_lock_for_mutation(
make_lock(),
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=WORKTREE,
)
self.assertTrue(verdict["block"])
self.assertTrue(any("not live" in reason for reason in verdict["reasons"]))
def test_no_manual_file_seeding_required(self):
# The whole decision is reachable from the durable record plus live
# observation; nothing is written to disk to reach a verdict.
self.assertTrue(assess()["recovery_sanctioned"])
def test_refusal_message_is_fail_closed(self):
message = issue_lock_recovery.format_recovery_refusal(
assess(porcelain_status=" M x.py\n")
)
self.assertIn("fail closed", message)
self.assertIn("recovery refused", message.lower())
if __name__ == "__main__":
unittest.main()
+618
View File
@@ -0,0 +1,618 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Dead-session lock recovery when the issue already owns an open PR (#755).
#753 added the recovery *assessor*, but the production ``gitea_lock_issue``
path still rejected every sanctioned recovery: a dead-session lock is by
construction a lock for work that already has an open PR, and the #400
duplicate-work gate blocked unconditionally on any linked open PR. These tests
drive the real MCP handler, not just the pure assessor, so that gap cannot
reopen.
"""
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_provenance # noqa: E402
import issue_lock_recovery # noqa: E402
import issue_lock_store # noqa: E402
import mcp_server # noqa: E402
from issue_work_duplicate_gate import ( # noqa: E402
OUTCOME_DUPLICATE_PR_PREVENTED,
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
PHASE_LOCK,
assess_work_issue_duplicate_gate,
)
ISSUE = 4755
BRANCH = f"fix/issue-{ISSUE}-owning-pr"
OTHER_BRANCH = f"fix/issue-{ISSUE}-competing"
HEAD = "c" * 40
OTHER_HEAD = "d" * 40
OWNING_PR = 4756
OTHER_PR = 4757
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
def dead_pid() -> int:
"""A PID that has certainly exited (spawned, then reaped)."""
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
return proc.pid
def shifted_ts(hours: int = 4) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
return {
"number": number,
"title": f"fix: something (Closes #{issue})",
"body": f"Closes #{issue}.",
"head": {"ref": ref, "sha": sha},
}
def sanctioned_token(
issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
):
"""The evidence shape the server derives from a granted recovery.
#768 extends the token with recorded/accepted heads and the head relation
so a strict-descendant recovery can still exempt the owning PR after the
remediation commit lands. Exact-head recovery (#753/#755) reports equal
heads under the same shape.
"""
return {
"issue_number": issue_number,
"pr_number": pr_number,
"branch_name": branch,
"head_sha": head,
"recorded_head": head,
"accepted_head": head,
"head_relation": issue_lock_recovery.HEAD_RELATION_EQUAL,
}
# ───────────────────────── duplicate gate: exemption ─────────────────────────
class TestOwningPrExemptionGranted(unittest.TestCase):
def test_exact_owning_pr_is_not_duplicate_work(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr()],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
self.assertFalse(result["block"])
self.assertTrue(result["owning_pr_recovery_exempted"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
self.assertEqual(result["linked_open_pr"], OWNING_PR)
self.assertEqual(result["linked_open_pr_count"], 1)
def test_unrelated_open_pr_alongside_owning_pr_is_ignored(self):
unrelated = {
"number": 999,
"title": "chore: unrelated",
"body": "no linkage",
"head": {"ref": "chore/unrelated", "sha": OTHER_HEAD},
}
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[unrelated, owning_pr()],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
self.assertFalse(result["block"])
self.assertTrue(result["owning_pr_recovery_exempted"])
self.assertEqual(result["linked_open_pr_count"], 1)
class TestOwningPrExemptionRefused(unittest.TestCase):
def assert_blocked(self, result):
self.assertTrue(result["block"])
self.assertFalse(result["owning_pr_recovery_exempted"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
def test_no_recovery_evidence_keeps_ordinary_blocker(self):
self.assert_blocked(
assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr()],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
)
)
def test_competing_pr_number_refused(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr(number=OTHER_PR)],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
self.assert_blocked(result)
def test_multiple_linked_open_prs_refused(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
self.assert_blocked(result)
self.assertEqual(result["linked_open_pr_count"], 2)
def test_different_branch_refused(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr(ref=OTHER_BRANCH)],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
self.assert_blocked(result)
def test_locked_branch_differing_from_evidence_refused(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr()],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=OTHER_BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
self.assert_blocked(result)
def test_different_head_refused(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr(sha=OTHER_HEAD)],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
self.assert_blocked(result)
def test_evidence_for_another_issue_refused(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr()],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(issue_number=ISSUE + 1),
)
self.assert_blocked(result)
def test_missing_head_in_live_pr_refused(self):
pr = owning_pr()
pr["head"] = {"ref": BRANCH}
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[pr],
branch_names=[BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
self.assert_blocked(result)
class TestOrdinaryDuplicateBehaviorUnchanged(unittest.TestCase):
def test_clean_issue_still_passes(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[],
branch_names=["feat/other-issue-99"],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
)
self.assertFalse(result["block"])
self.assertFalse(result["owning_pr_recovery_exempted"])
def test_competing_branch_still_blocks_even_with_owning_pr_evidence(self):
result = assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr()],
branch_names=[BRANCH, OTHER_BRANCH],
claim_entry={"status": "not_claimed"},
locked_branch=BRANCH,
phase=PHASE_LOCK,
recovered_owning_pr=sanctioned_token(),
)
# The owning PR is exempt, but the competing branch is not.
self.assertTrue(result["block"])
self.assertTrue(result["owning_pr_recovery_exempted"])
self.assertIn(OTHER_BRANCH, result["conflicting_branches"])
# ─────────────────── server-derived evidence cannot be forged ───────────────────
class TestOwningPrEvidenceDerivation(unittest.TestCase):
def granted(self, **evidence_overrides):
evidence = {
"issue_number": ISSUE,
"locked_branch": BRANCH,
"local_head": HEAD,
"remote_head": HEAD,
"pr_head": HEAD,
"pr_number": OWNING_PR,
}
evidence.update(evidence_overrides)
return {
"outcome": issue_lock_recovery.RECOVERY_SANCTIONED,
"recovery_sanctioned": True,
"is_candidate": True,
"reasons": [],
"evidence": evidence,
}
def test_granted_recovery_yields_evidence(self):
token = issue_lock_recovery.owning_pr_recovery_evidence(self.granted())
self.assertEqual(token, sanctioned_token())
def test_none_assessment_yields_nothing(self):
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(None))
def test_refused_assessment_yields_nothing(self):
refused = self.granted()
refused["outcome"] = issue_lock_recovery.REFUSED
refused["recovery_sanctioned"] = False
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(refused))
def test_sanctioned_flag_without_outcome_yields_nothing(self):
forged = self.granted()
forged["outcome"] = "SOMETHING_ELSE"
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(forged))
def test_missing_pr_number_yields_nothing(self):
self.assertIsNone(
issue_lock_recovery.owning_pr_recovery_evidence(
self.granted(pr_number=None)
)
)
def test_head_disagreement_in_evidence_yields_nothing(self):
self.assertIsNone(
issue_lock_recovery.owning_pr_recovery_evidence(
self.granted(remote_head=OTHER_HEAD)
)
)
self.assertIsNone(
issue_lock_recovery.owning_pr_recovery_evidence(
self.granted(local_head=OTHER_HEAD)
)
)
def test_real_refused_assessment_yields_nothing(self):
"""End-to-end against the real assessor, not a hand-built dict."""
lock = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": "/scratch/wt",
"remote": "prgs",
"org": "Example-Org",
"repo": "Example-Repo",
"session_pid": os.getpid(), # alive → must refuse
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"branch": BRANCH,
"worktree_path": "/scratch/wt",
"claimant": {"username": IDENTITY, "profile": PROFILE},
"expires_at": shifted_ts(),
},
}
assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
lock,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path="/scratch/wt",
remote="prgs",
org="Example-Org",
repo="Example-Repo",
identity=IDENTITY,
profile=PROFILE,
current_branch=BRANCH,
porcelain_status="",
head_sha=HEAD,
remote_head_sha=HEAD,
pr_head_sha=HEAD,
pr_number=OWNING_PR,
competing_live_locks=[],
candidate_branches=[BRANCH],
current_pid=os.getpid(),
)
self.assertFalse(assessment["recovery_sanctioned"])
self.assertIsNone(
issue_lock_recovery.owning_pr_recovery_evidence(assessment)
)
# ──────────────────── end-to-end: the real gitea_lock_issue ────────────────────
class LockIssueEndToEndBase(unittest.TestCase):
"""Drives ``mcp_server.gitea_lock_issue`` with live git/Gitea observation
stubbed at the module boundary the production gate chain itself runs."""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.worktree = os.path.realpath(os.getcwd())
# Bind host/org/repo to what the ``test-author-prgs`` fixture profile is
# pinned to, so the session-context gate under test is the real one and
# not a cross-host denial. The issue number and lock dir stay synthetic.
self.remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {
"host": "gitea.prgs.cc",
"org": ORG,
"repo": REPO,
},
})
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
def write_durable_lock(self, *, pid, branch=BRANCH, worktree=None):
path = issue_lock_store.lock_file_path(
remote="prgs",
org=ORG,
repo=REPO,
issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
claimant = {"username": IDENTITY, "profile": PROFILE}
data = {
"issue_number": ISSUE,
"branch_name": branch,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": worktree or self.worktree,
"session_pid": pid,
"pid": pid,
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"branch": branch,
"worktree_path": worktree or self.worktree,
"claimant": claimant,
"created_at": shifted_ts(-1),
"last_heartbeat_at": shifted_ts(-1),
"expires_at": shifted_ts(),
},
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=claimant,
),
}
issue_lock_store.save_lock_file(path, data)
return path
def run_lock(
self,
*,
open_prs,
porcelain="",
current_branch=BRANCH,
base_equivalent=False,
branch_names=None,
head_sha=HEAD,
remote_head=HEAD,
):
branch_names = branch_names if branch_names is not None else [BRANCH]
branch_entries = [
{"name": name, "commit": {"id": remote_head}} for name in branch_names
]
env = shared_mutation_env(
"test-author-prgs",
include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
with patch(
"mcp_server.api_get_all", return_value=branch_entries
), patch(
"mcp_server._list_open_pulls", return_value=list(open_prs)
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": IDENTITY, "profile": PROFILE},
), patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": current_branch,
"porcelain_status": porcelain,
"base_equivalent": base_equivalent,
"head_sha": head_sha,
"inspected_git_root": self.worktree,
"base_branch": "master",
},
), patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=lambda h, o, r, auth, issue_number: (
list(open_prs), list(branch_names), {"status": "not_claimed"}
),
):
with patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_lock_issue(
issue_number=ISSUE,
branch_name=BRANCH,
remote="prgs",
worktree_path=self.worktree,
)
class TestRecoveryWithOwningPrSucceeds(LockIssueEndToEndBase):
def test_dead_session_recovery_with_owning_pr_relocks(self):
self.write_durable_lock(pid=dead_pid())
result = self.run_lock(open_prs=[owning_pr()])
self.assertTrue(result["success"])
self.assertEqual(result["issue_number"], ISSUE)
self.assertEqual(result["branch_name"], BRANCH)
self.assertTrue(result["lock_freshness"]["live"])
self.assertTrue(result["lock_freshness"]["pid_alive"])
def test_recovered_lock_records_truthful_provenance(self):
prior = dead_pid()
self.write_durable_lock(pid=prior)
self.run_lock(open_prs=[owning_pr()])
lock = issue_lock_store.load_issue_lock(
remote="prgs",
org=ORG,
repo=REPO,
issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
record = lock.get("dead_session_recovery") or {}
self.assertTrue(record.get("recovered"))
self.assertEqual(record.get("prior_session_pid"), prior)
self.assertEqual(record.get("replacement_session_pid"), os.getpid())
self.assertFalse(record.get("prior_pid_alive"))
self.assertEqual(record.get("pr_number"), OWNING_PR)
self.assertEqual(record.get("branch_name"), BRANCH)
self.assertEqual(record.get("identity"), IDENTITY)
def test_recovered_lock_is_live_and_proves_pr_ownership(self):
"""AC6: the persisted lock satisfies update-by-merge's ownership prover."""
self.write_durable_lock(pid=dead_pid())
self.run_lock(open_prs=[owning_pr()])
lock = issue_lock_store.load_issue_lock(
remote="prgs",
org=ORG,
repo=REPO,
issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
self.assertTrue(issue_lock_store.is_lease_live(lock))
env = shared_mutation_env(
"test-author-prgs",
include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
with patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
ownership = mcp_server._prove_author_ownership_for_pr(
pr_number=OWNING_PR,
pr_title=f"fix: something (Closes #{ISSUE})",
pr_body=f"Closes #{ISSUE}.",
source_branch=BRANCH,
remote="prgs",
host="gitea.prgs.cc",
org=ORG,
repo=REPO,
worktree_path=self.worktree,
)
self.assertTrue(ownership["proven"], ownership["reasons"])
self.assertTrue(ownership["has_author_lock"])
self.assertEqual(ownership["matched_issue"], ISSUE)
class TestRecoveryRejectionsEndToEnd(LockIssueEndToEndBase):
def assert_lock_refused(self, **kwargs):
with self.assertRaises((ValueError, RuntimeError)) as ctx:
self.run_lock(**kwargs)
return str(ctx.exception)
def test_competing_pr_still_blocked(self):
self.write_durable_lock(pid=dead_pid())
message = self.assert_lock_refused(
open_prs=[owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
branch_names=[BRANCH],
)
self.assertIn("already covers issue", message)
def test_multiple_linked_open_prs_blocked(self):
self.write_durable_lock(pid=dead_pid())
message = self.assert_lock_refused(
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
)
self.assertIn("already covers issue", message)
def test_owning_pr_on_a_different_head_blocked(self):
self.write_durable_lock(pid=dead_pid())
self.assert_lock_refused(open_prs=[owning_pr(sha=OTHER_HEAD)])
def test_lock_registered_to_a_different_worktree_blocked(self):
self.write_durable_lock(
pid=dead_pid(), worktree=os.path.join(self.worktree, "elsewhere")
)
self.assert_lock_refused(open_prs=[owning_pr()])
def test_dirty_worktree_blocked(self):
self.write_durable_lock(pid=dead_pid())
self.assert_lock_refused(
open_prs=[owning_pr()], porcelain=" M gitea_mcp_server.py"
)
def test_worktree_parked_on_another_branch_blocked(self):
self.write_durable_lock(pid=dead_pid())
self.assert_lock_refused(
open_prs=[owning_pr()], current_branch="master"
)
def test_local_head_differing_from_remote_blocked(self):
self.write_durable_lock(pid=dead_pid())
self.assert_lock_refused(
open_prs=[owning_pr()], head_sha=OTHER_HEAD
)
def test_live_prior_pid_blocked(self):
self.write_durable_lock(pid=os.getpid())
self.assert_lock_refused(open_prs=[owning_pr()])
def test_new_claim_without_prior_lock_still_requires_base_equivalence(self):
"""AC10: no durable lock → no recovery → base-equivalence still rules."""
self.assert_lock_refused(open_prs=[], branch_names=[])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,809 @@
"""Regression tests for #757: #274 and #604 must not disagree.
The sanctioned ``create_issue`` bootstrap (#749/#750) was unreachable in
production: the #274 branches-only guard consulted the bootstrap and permitted
a clean canonical control checkout, then the bootstrap-blind #604 anti-stomp
preflight rejected the same checkout as ``wrong_worktree``.
These tests pin the fix:
* one server-derived assessment, interpreted by one shared predicate;
* the waiver is narrow (only the wrong-worktree verdict, only create_issue,
only from the exact clean canonical control checkout);
* every other guard and rejection reason keeps its fail-closed behaviour;
* eligibility cannot be forged through a public tool signature.
"""
from __future__ import annotations
import inspect
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import anti_stomp_preflight as asp # noqa: E402
import create_issue_bootstrap as cib # noqa: E402
import gitea_mcp_server as srv # noqa: E402
FAKE_AUTH = {"Authorization": "token test-token"}
MASTER_SHA = "a" * 40
STALE_SHA = "b" * 40
current_file_path = Path(__file__).resolve()
if "branches" in current_file_path.parts:
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
else:
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
BRANCHES_WORKTREE = str(Path(CONTROL_CHECKOUT_ROOT) / "branches" / "issue-1-x")
def proven_bootstrap(
*,
workspace=CONTROL_CHECKOUT_ROOT,
root=CONTROL_CHECKOUT_ROOT,
task="create_issue",
branch="master",
porcelain="",
head=MASTER_SHA,
remote_sha=MASTER_SHA,
):
"""Build a real assessment via the production assessor (never hand-rolled)."""
return cib.assess_create_issue_bootstrap(
workspace_path=workspace,
canonical_repo_root=root,
current_branch=branch,
head_sha=head,
porcelain_status=porcelain,
remote_master_sha=remote_sha,
task=task,
)
class TestSharedPredicate(unittest.TestCase):
"""The one interpretation both guards consume (AC6, AC7)."""
def _permits(self, assessment, task="create_issue", workspace=None, root=None):
return cib.bootstrap_permits_control_checkout(
assessment,
task=task,
workspace_path=workspace or CONTROL_CHECKOUT_ROOT,
canonical_repo_root=root or CONTROL_CHECKOUT_ROOT,
)
def test_proven_bootstrap_permits(self):
self.assertTrue(self._permits(proven_bootstrap()))
def test_tool_alias_permits(self):
assessment = proven_bootstrap(task="gitea_create_issue")
self.assertTrue(self._permits(assessment, task="gitea_create_issue"))
def test_missing_assessment_fails_closed(self):
self.assertFalse(self._permits(None))
def test_malformed_assessment_fails_closed(self):
for bogus in ("allowed", 1, True, [], ["allowed"], object()):
with self.subTest(bogus=bogus):
self.assertFalse(self._permits(bogus))
def test_refused_assessment_fails_closed(self):
# Dirty control checkout -> assessor blocks -> predicate must refuse.
self.assertFalse(
self._permits(proven_bootstrap(porcelain=" M gitea_mcp_server.py\n"))
)
def test_not_applicable_assessment_fails_closed(self):
# branches/ worktree -> not_applicable -> no waiver.
self.assertFalse(
self._permits(proven_bootstrap(workspace=BRANCHES_WORKTREE))
)
def test_incomplete_assessment_fails_closed(self):
incomplete = dict(proven_bootstrap())
del incomplete["bootstrap_path"]
self.assertFalse(self._permits(incomplete))
def test_contradictory_block_and_allow_fails_closed(self):
contradictory = dict(proven_bootstrap(), block=True)
self.assertFalse(self._permits(contradictory))
def test_contradictory_dirty_but_allowed_fails_closed(self):
contradictory = dict(proven_bootstrap(), dirty_files=["gitea_mcp_server.py"])
self.assertFalse(self._permits(contradictory))
def test_contradictory_under_branches_but_allowed_fails_closed(self):
contradictory = dict(proven_bootstrap(), under_branches=True)
self.assertFalse(self._permits(contradictory))
def test_contradictory_reasons_present_fails_closed(self):
contradictory = dict(proven_bootstrap(), reasons=["something refused"])
self.assertFalse(self._permits(contradictory))
def test_truthy_non_true_values_fail_closed(self):
"""Strict identity: no truthy smuggling (1, 'yes') can assert eligibility."""
for value in (1, "yes", "true", [1]):
with self.subTest(value=value):
self.assertFalse(self._permits(dict(proven_bootstrap(), allowed=value)))
def test_wrong_task_fails_closed(self):
"""An otherwise-proven assessment cannot license a different task."""
self.assertFalse(self._permits(proven_bootstrap(), task="lock_issue"))
self.assertFalse(self._permits(proven_bootstrap(), task="create_pr"))
self.assertFalse(self._permits(proven_bootstrap(), task=None))
def test_foreign_workspace_binding_fails_closed(self):
"""Assessment must describe the workspace actually being guarded."""
other = proven_bootstrap(workspace="/other/clone", root="/other/clone")
self.assertFalse(self._permits(other))
def test_scope_and_path_tampering_fails_closed(self):
self.assertFalse(
self._permits(dict(proven_bootstrap(), task_scope="all_tasks"))
)
self.assertFalse(
self._permits(dict(proven_bootstrap(), bootstrap_path="anything_goes"))
)
class TestAntiStompHonorsBootstrap(unittest.TestCase):
"""#604 consumes the same decision, and waives only wrong_worktree."""
def _assess(self, *, task="create_issue", bootstrap=None, workspace=None, **kw):
params = dict(
task=task,
profile_role="author",
required_role="author",
workspace_path=workspace or CONTROL_CHECKOUT_ROOT,
project_root=CONTROL_CHECKOUT_ROOT,
current_branch="master",
root_head_sha=MASTER_SHA,
root_porcelain="",
remote_master_sha=MASTER_SHA,
check_repo=False,
create_issue_bootstrap_assessment=bootstrap,
)
params.update(kw)
return asp.assess_anti_stomp_preflight(**params)
def _blocker_kinds(self, result):
return {b["kind"] for b in result.get("blockers") or []}
def test_control_checkout_without_bootstrap_still_blocked(self):
"""Baseline: the exact production failure, unwaived."""
res = self._assess(bootstrap=None)
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
def test_control_checkout_with_proven_bootstrap_permitted(self):
"""AC1/AC2: the #757 fix — same inputs, bootstrap honoured."""
res = self._assess(bootstrap=proven_bootstrap())
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
self.assertTrue(res["checks"]["worktree"]["create_issue_bootstrap_waived"])
self.assertFalse(res["checks"]["worktree"]["block"])
def test_tool_alias_permitted(self):
res = self._assess(
task="gitea_create_issue",
bootstrap=proven_bootstrap(task="gitea_create_issue"),
)
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
def test_non_create_issue_task_never_waived(self):
"""AC5: other author mutations keep the branches-only requirement."""
for task in ("lock_issue", "create_pr", "commit_files", "mark_issue"):
with self.subTest(task=task):
res = self._assess(task=task, bootstrap=proven_bootstrap())
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
def test_dirty_control_checkout_still_blocked(self):
"""AC4: dirty root refuses the bootstrap, so no waiver."""
dirty = " M gitea_mcp_server.py\n"
res = self._assess(
bootstrap=proven_bootstrap(porcelain=dirty), root_porcelain=dirty
)
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
def test_detached_control_checkout_still_blocked(self):
res = self._assess(
bootstrap=proven_bootstrap(branch=""), current_branch=""
)
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
def test_base_divergence_still_blocked(self):
res = self._assess(
bootstrap=proven_bootstrap(head=STALE_SHA), root_head_sha=STALE_SHA
)
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
def test_waiver_does_not_suppress_stale_runtime(self):
"""AC: waive only wrong_worktree — stale runtime still fails closed."""
res = self._assess(
bootstrap=proven_bootstrap(),
startup_head=STALE_SHA,
current_code_head=MASTER_SHA,
)
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
self.assertIn(asp.BLOCKER_STALE_RUNTIME, self._blocker_kinds(res))
self.assertTrue(res["block"])
def test_waiver_does_not_suppress_wrong_repo(self):
res = self._assess(
bootstrap=proven_bootstrap(),
check_repo=True,
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Timesheet",
# Not both-explicit, so the #530 repo guard actually evaluates the
# mismatch against the local remote instead of trusting the caller.
org_explicit=False,
repo_explicit=False,
local_remote_url=(
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
),
)
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
self.assertIn(asp.BLOCKER_WRONG_REPO, self._blocker_kinds(res))
def test_waiver_does_not_suppress_wrong_role(self):
res = self._assess(
bootstrap=proven_bootstrap(),
profile_role="reviewer",
required_role="author",
)
self.assertIn(asp.BLOCKER_WRONG_ROLE, self._blocker_kinds(res))
def test_branches_worktree_unaffected(self):
"""AC: ordinary branches/ worktrees keep working, waived or not."""
for bootstrap in (None, proven_bootstrap(workspace=BRANCHES_WORKTREE)):
with self.subTest(bootstrap=bool(bootstrap)):
res = self._assess(
workspace=BRANCHES_WORKTREE,
bootstrap=bootstrap,
current_branch="fix/issue-1-x",
)
self.assertNotIn(
asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res)
)
def test_forged_assessment_rejected(self):
"""AC6: a hand-built 'allowed' dict cannot unlock the waiver."""
forged = {"allowed": True, "block": False}
res = self._assess(bootstrap=forged)
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
class TestGuardAgreement(unittest.TestCase):
"""AC7: the regression that would have caught the #757 defect.
For identical evidence, the #274 guard and the #604 guard must return the
same wrong-worktree verdict across the full workspace-state matrix.
"""
MATRIX = [
(
"clean control + create_issue",
CONTROL_CHECKOUT_ROOT, "create_issue", "master", "", MASTER_SHA,
),
(
"clean control + alias",
CONTROL_CHECKOUT_ROOT, "gitea_create_issue", "master", "", MASTER_SHA,
),
(
"clean control + lock_issue",
CONTROL_CHECKOUT_ROOT, "lock_issue", "master", "", MASTER_SHA,
),
(
"clean control + create_pr",
CONTROL_CHECKOUT_ROOT, "create_pr", "master", "", MASTER_SHA,
),
(
"dirty control + create_issue",
CONTROL_CHECKOUT_ROOT, "create_issue", "master",
" M gitea_mcp_server.py\n", MASTER_SHA,
),
(
"detached control + create_issue",
CONTROL_CHECKOUT_ROOT, "create_issue", "", "", MASTER_SHA,
),
(
"non-base control + create_issue",
CONTROL_CHECKOUT_ROOT, "create_issue", "feat/x", "", MASTER_SHA,
),
(
"diverged control + create_issue",
CONTROL_CHECKOUT_ROOT, "create_issue", "master", "", STALE_SHA,
),
(
"branches wt + create_issue",
BRANCHES_WORKTREE, "create_issue", "fix/issue-1-x", "", MASTER_SHA,
),
(
"branches wt + lock_issue",
BRANCHES_WORKTREE, "lock_issue", "fix/issue-1-x", "", MASTER_SHA,
),
]
def _guard_274_blocks(self, *, workspace, task, branch, porcelain, head, bootstrap):
git_state = {
"current_branch": branch,
"head_sha": head,
"porcelain_status": porcelain,
}
ctx = {
"workspace_path": workspace,
"canonical_repo_root": CONTROL_CHECKOUT_ROOT,
}
with patch.object(srv, "_effective_workspace_role", return_value="author"), \
patch.object(srv, "_actual_profile_role", return_value="author"), \
patch.object(
srv, "_resolve_namespace_mutation_context", return_value=ctx
), \
patch.object(
srv.issue_lock_worktree,
"read_worktree_git_state",
return_value=git_state,
):
try:
srv._enforce_branches_only_author_mutation(
workspace, task=task, bootstrap_assessment=bootstrap
)
return False
except RuntimeError:
return True
def _guard_604_blocks(self, *, workspace, task, branch, porcelain, head, bootstrap):
res = asp.assess_anti_stomp_preflight(
task=task,
profile_role="author",
required_role="author",
workspace_path=workspace,
project_root=CONTROL_CHECKOUT_ROOT,
current_branch=branch,
root_head_sha=head,
root_porcelain=porcelain,
remote_master_sha=MASTER_SHA,
check_repo=False,
create_issue_bootstrap_assessment=bootstrap,
)
return asp.BLOCKER_WRONG_WORKTREE in {
b["kind"] for b in res.get("blockers") or []
}
def test_guards_agree_across_matrix(self):
for label, workspace, task, branch, porcelain, head in self.MATRIX:
with self.subTest(case=label):
# ONE server-derived assessment, exactly as production computes it.
bootstrap = cib.assess_create_issue_bootstrap(
workspace_path=workspace,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
current_branch=branch,
head_sha=head,
porcelain_status=porcelain,
remote_master_sha=MASTER_SHA,
task=task,
)
kwargs = dict(
workspace=workspace,
task=task,
branch=branch,
porcelain=porcelain,
head=head,
bootstrap=bootstrap,
)
blocked_274 = self._guard_274_blocks(**kwargs)
blocked_604 = self._guard_604_blocks(**kwargs)
self.assertEqual(
blocked_274,
blocked_604,
f"{label}: #274 blocked={blocked_274} "
f"but #604 blocked={blocked_604}",
)
def test_clean_control_create_issue_permitted_by_both(self):
"""The specific case that was broken: both guards must permit."""
kwargs = dict(
workspace=CONTROL_CHECKOUT_ROOT,
task="create_issue",
branch="master",
porcelain="",
head=MASTER_SHA,
bootstrap=proven_bootstrap(),
)
self.assertFalse(self._guard_274_blocks(**kwargs))
self.assertFalse(self._guard_604_blocks(**kwargs))
class TestNoCallerForgeableEligibility(unittest.TestCase):
"""AC6: eligibility is never reachable through a public tool signature."""
def test_create_issue_tool_exposes_no_bootstrap_argument(self):
params = set(inspect.signature(srv.gitea_create_issue).parameters)
for forbidden in (
"bootstrap",
"bootstrap_assessment",
"create_issue_bootstrap",
"create_issue_bootstrap_assessment",
"allow_control_checkout",
"bootstrap_allowed",
):
self.assertNotIn(forbidden, params)
def test_no_mcp_tool_exposes_bootstrap_argument(self):
"""No public gitea_* tool may take bootstrap evidence from the caller."""
for name in dir(srv):
if not name.startswith("gitea_"):
continue
fn = getattr(srv, name)
if not callable(fn):
continue
try:
params = set(inspect.signature(fn).parameters)
except (TypeError, ValueError):
continue
leaked = {p for p in params if "bootstrap" in p.lower()}
self.assertFalse(leaked, f"{name} exposes bootstrap args: {leaked}")
def test_internal_helper_yields_nothing_for_other_tasks(self):
self.assertTrue(hasattr(srv, "_create_issue_bootstrap_assessment"))
self.assertIsNone(
srv._create_issue_bootstrap_assessment("lock_issue"),
"non-create_issue tasks must yield no bootstrap evidence",
)
class TestNativeCreateIssueEndToEnd(unittest.TestCase):
"""AC8: the production handler, with the #604 gate LIVE (not patched out).
The pre-existing #749 e2e test patched ``_run_anti_stomp_preflight`` to a
no-op, which is exactly why this defect reached production. These tests
leave it running.
"""
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_resolved_task = "create_issue"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
srv._preflight_resolved_task = None
def _git_state(self, branch="master", head=MASTER_SHA, porcelain=""):
return {
"current_branch": branch,
"head_sha": head,
"porcelain_status": porcelain,
}
def _run_create_issue(
self,
*,
git_state,
remote_sha=MASTER_SHA,
parity=None,
title="Bootstrap issue from clean control",
):
"""Invoke the native handler with the anti-stomp gate live."""
parity = parity or {
"startup_head": MASTER_SHA,
"current_head": MASTER_SHA,
}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
patch.object(srv, "_auth", return_value=FAKE_AUTH), \
patch.object(srv, "_profile_permission_block", return_value=None), \
patch.object(srv, "_namespace_mutation_block", return_value=None), \
patch.object(
srv.role_session_router,
"check_author_mutation_after_reviewer_stop",
return_value=(True, []),
), \
patch.object(srv, "api_get_all", return_value=[]), \
patch.object(srv, "api_request") as mock_api, \
patch.object(
srv.root_checkout_guard,
"resolve_remote_master_sha",
return_value=remote_sha,
), \
patch.object(srv, "_current_master_parity", return_value=parity), \
patch.object(
srv,
"get_profile",
return_value={
"profile_name": "prgs-author",
"allowed_operations": [
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.read",
],
},
), \
patch.object(srv, "_actual_profile_role", return_value="author"), \
patch.object(srv, "_effective_workspace_role", return_value="author"), \
patch.object(
srv.issue_lock_worktree,
"read_worktree_git_state",
return_value=git_state,
), \
patch.object(
srv,
"_get_workspace_porcelain",
return_value=git_state["porcelain_status"],
), \
patch.object(srv, "_enforce_root_checkout_guard"):
mock_api.return_value = {
"number": 99,
"html_url": "https://gitea.example.com/issues/99",
}
try:
result = srv.gitea_create_issue(
title=title,
body="Body text for content gate.",
)
except RuntimeError as exc:
return {"raised": str(exc)}, mock_api
return result, mock_api
def test_clean_control_checkout_reaches_api(self):
"""The exact production failure that blocked filing #757 and #758."""
res, mock_api = self._run_create_issue(git_state=self._git_state())
self.assertNotIn("raised", res, f"guard still blocks: {res.get('raised')}")
self.assertEqual(res.get("number"), 99)
mock_api.assert_called_once()
def test_dirty_control_checkout_blocked(self):
dirty = " M gitea_mcp_server.py\n"
res, mock_api = self._run_create_issue(
git_state=self._git_state(porcelain=dirty)
)
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
mock_api.assert_not_called()
def test_detached_control_checkout_blocked(self):
res, mock_api = self._run_create_issue(git_state=self._git_state(branch=""))
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
mock_api.assert_not_called()
def test_base_mismatch_blocked(self):
res, mock_api = self._run_create_issue(
git_state=self._git_state(head=STALE_SHA)
)
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
mock_api.assert_not_called()
def test_stale_runtime_blocked(self):
res, mock_api = self._run_create_issue(
git_state=self._git_state(),
parity={"startup_head": STALE_SHA, "current_head": MASTER_SHA},
)
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
mock_api.assert_not_called()
def test_non_create_issue_mutation_still_blocked_from_control(self):
"""AC5 through the real preflight, with anti-stomp live."""
srv._preflight_resolved_task = "lock_issue"
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
patch.object(srv, "_actual_profile_role", return_value="author"), \
patch.object(srv, "_effective_workspace_role", return_value="author"), \
patch.object(
srv.issue_lock_worktree,
"read_worktree_git_state",
return_value=self._git_state(),
), \
patch.object(srv, "_get_workspace_porcelain", return_value=""), \
patch.object(
srv.root_checkout_guard,
"resolve_remote_master_sha",
return_value=MASTER_SHA,
), \
patch.object(srv, "_enforce_root_checkout_guard"):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity(remote="prgs", task="lock_issue")
self.assertIn("control checkout", str(ctx.exception))
class TestBaseEquivalenceProofRequired(unittest.TestCase):
"""#757 AC3/AC4: an unknown tip is not evidence of agreement.
The original fix compared SHAs only inside
``if remote_tip and local_tip and remote_tip != local_tip``, so a missing
local HEAD, an unresolvable live master, or a resolver failure all fell
through and *granted* the bootstrap exemption. Base equivalence must be
proven, and anything less must fail closed.
"""
def _permits(self, assessment):
return cib.bootstrap_permits_control_checkout(
assessment,
task="create_issue",
workspace_path=CONTROL_CHECKOUT_ROOT,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
)
def _assert_fails_closed(self, assessment, *, expect_reason):
self.assertTrue(assessment["block"], "assessor must block")
self.assertFalse(assessment["allowed"])
self.assertFalse(assessment["proven"])
self.assertFalse(assessment["base_tips_verified"])
self.assertFalse(self._permits(assessment), "predicate must refuse")
joined = " ".join(assessment["reasons"]).lower()
self.assertIn(expect_reason, joined)
def test_missing_local_head_fails_closed(self):
self._assert_fails_closed(
proven_bootstrap(head=None),
expect_reason="control checkout head sha is unknown",
)
def test_missing_remote_master_fails_closed(self):
self._assert_fails_closed(
proven_bootstrap(remote_sha=None),
expect_reason="live master tip is unknown",
)
def test_both_tips_missing_fails_closed(self):
assessment = proven_bootstrap(head=None, remote_sha=None)
self._assert_fails_closed(
assessment, expect_reason="control checkout head sha is unknown"
)
self.assertIn("live master tip is unknown", " ".join(assessment["reasons"]))
def test_empty_and_whitespace_tips_fail_closed(self):
for head, remote in (("", MASTER_SHA), (MASTER_SHA, ""), (" ", " ")):
with self.subTest(head=repr(head), remote=repr(remote)):
assessment = proven_bootstrap(head=head, remote_sha=remote)
self.assertTrue(assessment["block"])
self.assertFalse(self._permits(assessment))
def test_mismatched_tips_fail_closed(self):
self._assert_fails_closed(
proven_bootstrap(head=STALE_SHA),
expect_reason="does not match",
)
def test_resolver_failure_fails_closed(self):
assessment = cib.assess_create_issue_bootstrap(
workspace_path=CONTROL_CHECKOUT_ROOT,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
current_branch="master",
head_sha=MASTER_SHA,
porcelain_status="",
remote_master_sha=None,
remote_master_sha_error="TimeoutError: remote unreachable",
task="create_issue",
)
self._assert_fails_closed(
assessment, expect_reason="could not be resolved"
)
# The operator must be able to see *why*, not just that it blocked.
self.assertIn("remote unreachable", " ".join(assessment["reasons"]))
def test_proven_bootstrap_records_both_normalized_shas(self):
assessment = proven_bootstrap()
self.assertEqual(assessment["local_head_sha"], MASTER_SHA)
self.assertEqual(assessment["remote_master_sha"], MASTER_SHA)
self.assertTrue(assessment["base_tips_verified"])
self.assertTrue(self._permits(assessment))
def test_tips_are_normalized_before_comparison(self):
"""Case and surrounding whitespace are not a different commit."""
assessment = proven_bootstrap(
head=f" {MASTER_SHA.upper()} ", remote_sha=MASTER_SHA
)
self.assertTrue(assessment["allowed"])
self.assertEqual(assessment["local_head_sha"], MASTER_SHA)
self.assertTrue(self._permits(assessment))
def test_predicate_rejects_assessment_with_tips_stripped(self):
"""A recorded proof that is later removed cannot still permit."""
for field in ("local_head_sha", "remote_master_sha"):
with self.subTest(field=field):
assessment = dict(proven_bootstrap())
assessment[field] = None
self.assertFalse(self._permits(assessment))
def test_predicate_rejects_forged_verified_flag(self):
"""base_tips_verified is re-derived, never trusted on its own."""
assessment = dict(proven_bootstrap())
assessment["local_head_sha"] = MASTER_SHA
assessment["remote_master_sha"] = STALE_SHA
assessment["base_tips_verified"] = True
self.assertFalse(self._permits(assessment))
def test_predicate_rejects_missing_verified_flag(self):
assessment = dict(proven_bootstrap())
assessment.pop("base_tips_verified")
self.assertFalse(self._permits(assessment))
class TestServerAssessmentFailsClosedOnResolverError(unittest.TestCase):
"""AC3/AC4 at the single server-derived computation site."""
def setUp(self):
# verify_preflight_purity short-circuits under pytest; the production
# path only runs with test mode disabled (same setup the #757 e2e uses).
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
srv._preflight_resolved_task = None
def _git_state(self):
return {
"current_branch": "master",
"head_sha": MASTER_SHA,
"porcelain_status": "",
}
def test_resolver_exception_produces_blocking_assessment(self):
"""A raising resolver must not become a silent, permissive None."""
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
patch.object(
srv.issue_lock_worktree,
"read_worktree_git_state",
return_value=self._git_state(),
), \
patch.object(
srv.root_checkout_guard,
"resolve_remote_master_sha",
side_effect=TimeoutError("remote unreachable"),
):
assessment = srv._create_issue_bootstrap_assessment("create_issue")
self.assertIsNotNone(assessment)
self.assertTrue(assessment["block"])
self.assertFalse(assessment["allowed"])
self.assertFalse(assessment["base_tips_verified"])
self.assertIsNone(assessment["remote_master_sha"])
self.assertIn("could not be resolved", " ".join(assessment["reasons"]))
self.assertFalse(
cib.bootstrap_permits_control_checkout(
assessment,
task="create_issue",
workspace_path=CONTROL_CHECKOUT_ROOT,
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
)
)
def test_resolver_exception_blocks_real_preflight(self):
"""Production path: verify_preflight_purity must fail closed."""
srv._preflight_resolved_task = "create_issue"
try:
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
patch.object(srv, "_actual_profile_role", return_value="author"), \
patch.object(
srv, "_effective_workspace_role", return_value="author"
), \
patch.object(
srv.issue_lock_worktree,
"read_worktree_git_state",
return_value=self._git_state(),
), \
patch.object(srv, "_get_workspace_porcelain", return_value=""), \
patch.object(
srv.root_checkout_guard,
"resolve_remote_master_sha",
side_effect=TimeoutError("remote unreachable"),
), \
patch.object(srv, "_enforce_root_checkout_guard"):
with self.assertRaises(RuntimeError):
srv.verify_preflight_purity(remote="prgs", task="create_issue")
finally:
srv._preflight_resolved_task = None
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,395 @@
"""Regression coverage for reviewer-lease preflight ordering (#763)."""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
import anti_stomp_preflight
import gitea_mcp_server as server
import merger_lease_adoption
import reviewer_pr_lease
import task_capability_map
def _prime_clean_reviewer_preflight(monkeypatch, resolved_task: str) -> None:
"""Install a clean reviewer preflight without bypassing task matching."""
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
monkeypatch.delenv("GITEA_TEST_FORCE_DIRTY", raising=False)
monkeypatch.setattr(server, "_preflight_in_test_mode", lambda: False)
monkeypatch.setattr(server, "_process_start_porcelain", "")
monkeypatch.setattr(server, "_preflight_whoami_called", False)
monkeypatch.setattr(server, "_preflight_capability_called", False)
monkeypatch.setattr(server, "_preflight_whoami_violation", False)
monkeypatch.setattr(server, "_preflight_capability_violation", False)
monkeypatch.setattr(server, "_preflight_resolved_role", None)
monkeypatch.setattr(server, "_preflight_resolved_task", None)
monkeypatch.setattr(server, "_preflight_whoami_baseline_porcelain", None)
monkeypatch.setattr(server, "_preflight_capability_baseline_porcelain", None)
monkeypatch.setattr(server, "_preflight_whoami_violation_files", [])
monkeypatch.setattr(server, "_preflight_capability_violation_files", [])
monkeypatch.setattr(server, "_preflight_reviewer_violation_files", [])
monkeypatch.setattr(
server,
"_resolve_namespace_mutation_context",
lambda _worktree=None: {
"workspace_path": server.PROJECT_ROOT,
"canonical_repo_root": server.PROJECT_ROOT,
"process_project_root": server.PROJECT_ROOT,
"workspace_role_kind": "reviewer",
"workspace_binding_source": "test reviewer binding",
"ignored_bindings": [],
},
)
monkeypatch.setattr(server, "_enforce_stable_branch_contamination_gate", lambda *_a: None)
monkeypatch.setattr(server, "_enforce_canonical_repository_root", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_enforce_root_checkout_guard", lambda *_a: None)
monkeypatch.setattr(server, "_enforce_branches_only_author_mutation", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_enforce_issue_scope_guard", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_create_issue_bootstrap_assessment", lambda *_a: None)
monkeypatch.setattr(server, "_run_anti_stomp_preflight", lambda *_a, **_k: None)
server.record_preflight_check("whoami")
server.record_preflight_check(
"capability", resolved_role="reviewer", resolved_task=resolved_task
)
def test_documented_review_capability_allows_reviewer_lease_acquire(monkeypatch):
"""whoami -> resolve(review_pr) -> acquire reviewer lease is canonical."""
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
assert server._preflight_capability_called is False
def test_exact_lease_capability_without_intervening_call_still_succeeds(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "acquire_reviewer_pr_lease")
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
assert server._preflight_capability_called is False
def test_missing_wrong_and_consumed_capability_fail_closed(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "create_issue")
with pytest.raises(RuntimeError, match="task mismatch"):
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
_prime_clean_reviewer_preflight(monkeypatch, "acquire_reviewer_pr_lease")
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
with pytest.raises(RuntimeError, match="has not been resolved"):
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
def test_documented_intervening_whoami_read_preserves_capability(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
with patch.object(server, "_get_workspace_porcelain", return_value=""):
server.record_preflight_check("whoami")
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
def test_reviewer_transition_is_narrow_alias_aware_and_one_way():
assert task_capability_map.preflight_task_matches(
"review_pr", "gitea_acquire_reviewer_pr_lease"
)
assert task_capability_map.preflight_task_matches(
"gitea_acquire_reviewer_pr_lease", "acquire_reviewer_pr_lease"
)
assert not task_capability_map.preflight_task_matches(
"acquire_reviewer_pr_lease", "review_pr"
)
assert not task_capability_map.preflight_task_matches(
"review_pr", "acquire_merger_pr_lease"
)
assert not task_capability_map.preflight_task_matches(
"merge_pr", "acquire_reviewer_pr_lease"
)
def test_dirty_reviewer_workspace_still_fails_closed(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
monkeypatch.setenv("GITEA_TEST_PORCELAIN", " M gitea_mcp_server.py\n")
with pytest.raises(RuntimeError, match="Reviewer role violation"):
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
def test_mismatched_reviewer_workspace_still_fails_closed(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
monkeypatch.setattr(
server,
"_resolve_namespace_mutation_context",
lambda _worktree=None: {
"workspace_path": "/outside/review-pr-762",
"canonical_repo_root": "/repo",
"process_project_root": "/repo",
"workspace_role_kind": "reviewer",
"workspace_binding_source": "test reviewer binding",
"ignored_bindings": [],
},
)
monkeypatch.setattr(
server.author_mutation_worktree,
"assess_workspace_repo_membership",
lambda **_kwargs: {"block": True, "reasons": ["workspace mismatch"]},
)
monkeypatch.setattr(
server.author_mutation_worktree,
"format_workspace_repo_membership_error",
lambda _assessment: "workspace mismatch (fail closed)",
)
with pytest.raises(RuntimeError, match="workspace mismatch"):
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
def test_reviewer_lease_acquire_requires_workflow_load_proof(monkeypatch):
sha = "a" * 40
monkeypatch.setattr(server, "_anti_stomp_in_test_mode", lambda: False)
monkeypatch.setattr(
server,
"get_profile",
lambda: {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.review",
],
},
)
monkeypatch.setattr(server, "_actual_profile_role", lambda: "reviewer")
monkeypatch.setattr(
server,
"_resolve_namespace_mutation_context",
lambda _worktree=None: {
"workspace_path": "/repo/branches/review-pr-762",
"canonical_repo_root": "/repo",
"process_project_root": "/repo",
},
)
monkeypatch.setattr(
server.issue_lock_worktree,
"read_worktree_git_state",
lambda _path: {
"current_branch": "master",
"head_sha": sha,
"porcelain_status": "",
},
)
monkeypatch.setattr(
server.root_checkout_guard,
"resolve_remote_master_sha",
lambda _path: sha,
)
monkeypatch.setattr(
server,
"_current_master_parity",
lambda: {"startup_head": sha, "current_head": sha},
)
monkeypatch.setattr(server, "_local_git_remote_url", lambda _remote: None)
monkeypatch.setattr(server, "_load_stable_contamination_marker", lambda _remote: None)
monkeypatch.setattr(
server,
"_review_workflow_load_gate_reasons",
lambda: ["canonical review workflow proof missing"],
)
with pytest.raises(RuntimeError, match="workflow"):
server._run_anti_stomp_preflight(
"acquire_reviewer_pr_lease",
remote="prgs",
worktree_path="/repo/branches/review-pr-762",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
def test_whoami_identity_mismatch_invalidates_preflight(monkeypatch):
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
monkeypatch.setattr(server, "_process_start_porcelain", "")
monkeypatch.setattr(server, "_preflight_whoami_called", False)
monkeypatch.setattr(server, "_preflight_capability_called", True)
monkeypatch.setattr(server, "_auth", lambda _host: "redacted")
monkeypatch.setattr(
server,
"api_request",
lambda *_args, **_kwargs: {"login": "wrong-reviewer", "id": 7},
)
monkeypatch.setattr(
server,
"get_profile",
lambda: {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"username": "sysadmin",
"allowed_operations": ["gitea.read", "gitea.pr.review"],
"forbidden_operations": [],
},
)
monkeypatch.setattr(server, "_seed_session_context", lambda **_kwargs: None)
monkeypatch.setattr(server.session_ctx, "mutation_context_audit_fields", lambda: {})
monkeypatch.setattr(server, "_reveal_endpoints", lambda: False)
result = server.gitea_whoami(remote="prgs")
assert result["identity_match"] is False
assert server._preflight_whoami_called is False
assert server._preflight_capability_called is False
def test_denied_reviewer_profile_does_not_leave_capability_proof(monkeypatch):
profile = {
"profile_name": "prgs-author",
"role": "author",
"username": "jcwalker3",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.review",
],
"forbidden_operations": [],
}
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
monkeypatch.setattr(server, "_process_start_porcelain", "")
monkeypatch.setattr(server, "get_profile", lambda: profile)
monkeypatch.setattr(
server.gitea_config,
"load_config",
lambda: {"profiles": {"prgs-author": profile}},
)
monkeypatch.setattr(server.gitea_config, "is_runtime_switching_enabled", lambda: False)
monkeypatch.setattr(server, "_authenticated_username", lambda _host: "jcwalker3")
monkeypatch.setattr(server, "_seed_session_context", lambda **_kwargs: None)
monkeypatch.setattr(
server.session_ctx,
"assess_session_context",
lambda **_kwargs: {"block": False, "reasons": []},
)
monkeypatch.setattr(
server.session_ctx,
"assess_identity_match",
lambda **_kwargs: {"block": False, "reasons": []},
)
monkeypatch.setattr(
server.session_ctx,
"profile_allowed_for_remote",
lambda *_args, **_kwargs: {"block": False, "reasons": []},
)
monkeypatch.setattr(server.session_ctx, "mutation_context_audit_fields", lambda: {})
monkeypatch.setattr(
server.role_session_router,
"assess_infra_stop",
lambda _root: {"infra_stop": False, "infra_stop_reasons": []},
)
monkeypatch.setattr(server, "_check_mcp_runtimes_diagnostics", lambda *_a: [])
monkeypatch.setattr(
server,
"_assess_stale_active_binding",
lambda **_kwargs: {"classification": "unbound"},
)
monkeypatch.setattr(server, "record_mutation_authority", lambda *_args: None)
monkeypatch.setattr(server, "init_review_decision_lock", lambda *_a, **_k: None)
monkeypatch.setattr(server.capability_stop_terminal, "is_active", lambda: False)
monkeypatch.setattr(
server.capability_stop_terminal,
"sync_from_capability_result",
lambda _result: False,
)
result = server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
assert result["allowed_in_current_session"] is False
assert result["required_role_kind"] == "reviewer"
assert server._preflight_capability_called is False
def test_head_and_foreign_lease_protections_remain_enforced():
now = datetime.now(timezone.utc)
head = "a" * 40
moved_head = "b" * 40
body = reviewer_pr_lease.format_lease_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=762,
issue_number=605,
reviewer_identity="other-reviewer",
profile="prgs-reviewer",
session_id="foreign-session",
worktree="/repo/branches/review-pr-762",
phase="claimed",
candidate_head=head,
target_branch="master",
target_branch_sha="c" * 40,
last_activity=now,
)
comments = [{"id": 10, "author": "other-reviewer", "body": body}]
acquire = reviewer_pr_lease.assess_acquire_lease(
comments,
pr_number=762,
reviewer_identity="sysadmin",
profile="prgs-reviewer",
session_id="my-session",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=605,
worktree="/repo/branches/review-pr-762-mine",
candidate_head=head,
target_branch="master",
target_branch_sha="c" * 40,
now=now,
)
assert acquire["acquire_allowed"] is False
reviewer_pr_lease.clear_session_lease()
reviewer_pr_lease.record_session_lease(
{
"pr_number": 762,
"session_id": "foreign-session",
"candidate_head": head,
"comment_id": 10,
},
lease_provenance=merger_lease_adoption.build_lease_provenance(
source=merger_lease_adoption.SOURCE_ACQUIRE,
comment_id=10,
),
)
try:
gate = reviewer_pr_lease.assess_mutation_lease_gate(
pr_number=762,
comments=comments,
reviewer_identity="other-reviewer",
session_id="foreign-session",
mutation="approve",
live_head_sha=moved_head,
pinned_head_sha=head,
now=now,
)
finally:
reviewer_pr_lease.clear_session_lease()
assert gate["block"] is True
assert any("head changed" in reason for reason in gate["reasons"])
def test_reviewer_lease_role_gate_is_not_weakened():
result = anti_stomp_preflight.assess_anti_stomp_preflight(
task="acquire_reviewer_pr_lease",
profile_name="prgs-author",
profile_role="author",
required_role="reviewer",
required_permission="gitea.pr.comment",
allowed_operations=["gitea.read"],
check_repo=False,
check_root_checkout=False,
check_worktree=False,
check_stale_runtime=False,
)
assert result["block"] is True
assert result["blocker_kind"] == anti_stomp_preflight.BLOCKER_WRONG_ROLE
@@ -0,0 +1,551 @@
"""Strict-descendant dead-session recovery (#768).
After a dead author session, a preserved clean remediation commit that strictly
descends from the head recorded at lock time must be recoverable so the author
can publish. Equality alone is still accepted (#753); every other divergence
must keep failing closed.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_recovery # noqa: E402
import issue_lock_store # noqa: E402
import issue_lock_worktree # noqa: E402
import issue_work_duplicate_gate # noqa: E402
ISSUE = 7680
PR_NUMBER = 7681
BRANCH = f"fix/issue-{ISSUE}-descendant-recovery"
WORKTREE = "/scratch/wt-768"
RECORDED = "a" * 40
DESCENDANT = "c" * 40
DIVERGED = "d" * 40
BEHIND = "b" * 40
IDENTITY = "example-user"
PROFILE = "example-author"
def dead_pid() -> int:
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
return proc.pid
def future_ts(hours: int = 4) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
def make_lock(**overrides):
lock = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": WORKTREE,
"remote": "prgs",
"org": "ExampleOrg",
"repo": "ExampleRepo",
"session_pid": dead_pid(),
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"branch": BRANCH,
"worktree_path": WORKTREE,
"claimant": {"username": IDENTITY, "profile": PROFILE},
"expires_at": future_ts(),
},
}
lock.update(overrides)
return lock
def ancestry_ok(
*,
ancestor: str = RECORDED,
descendant: str = DESCENDANT,
is_strict: bool = True,
probe_ok: bool = True,
ancestor_present: bool = True,
reasons: list[str] | None = None,
) -> dict:
return {
"ancestor_sha": ancestor,
"descendant_sha": descendant,
"probe_ok": probe_ok,
"ancestor_present": ancestor_present,
"descendant_present": True,
"is_ancestor": is_strict or ancestor == descendant,
"is_strict_descendant": is_strict,
"proof": f"git merge-base --is-ancestor {ancestor} {descendant} -> exit 0",
"reasons": list(reasons or []),
}
def assess(**overrides):
kwargs = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": WORKTREE,
"remote": "prgs",
"org": "ExampleOrg",
"repo": "ExampleRepo",
"identity": IDENTITY,
"profile": PROFILE,
"current_branch": BRANCH,
"porcelain_status": "",
"head_sha": RECORDED,
"remote_head_sha": RECORDED,
"pr_head_sha": RECORDED,
"pr_number": PR_NUMBER,
"competing_live_locks": [],
"candidate_branches": [BRANCH],
"current_pid": os.getpid(),
"head_ancestry": None,
}
kwargs.update(overrides)
lock = kwargs.pop("lock", None)
return issue_lock_recovery.assess_dead_session_lock_recovery(
make_lock() if lock is None else lock, **kwargs
)
class TestExactHeadRecoveryStillSucceeds(unittest.TestCase):
def test_equal_heads_still_sanctioned(self):
result = assess()
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_EQUAL,
)
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
self.assertEqual(result["evidence"]["accepted_head"], RECORDED)
def test_exact_match_record_carries_relation(self):
record = issue_lock_recovery.build_recovery_record(
assess(), recovered_at="2026-07-20T00:00:00Z"
)
self.assertEqual(record["head_relation"], issue_lock_recovery.HEAD_RELATION_EQUAL)
self.assertEqual(record["recorded_head"], RECORDED)
self.assertEqual(record["accepted_head"], RECORDED)
class TestStrictDescendantRecoverySucceeds(unittest.TestCase):
def test_clean_strict_descendant_recovers(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
self.assertEqual(result["evidence"]["accepted_head"], DESCENDANT)
self.assertIsNotNone(result["evidence"]["ancestry_proof"])
self.assertTrue(
any("strictly descends" in r for r in result["reasons"]),
result["reasons"],
)
def test_pr_still_at_recorded_head_is_ok_for_descendant(self):
# Remediation is local only; open PR still points at the recorded head.
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
def test_recovery_record_names_both_heads_and_proof(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
record = issue_lock_recovery.build_recovery_record(
result, recovered_at="2026-07-20T00:00:00Z"
)
self.assertEqual(record["recorded_head"], RECORDED)
self.assertEqual(record["accepted_head"], DESCENDANT)
self.assertEqual(
record["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
self.assertIn("strictly descends", record["ancestry_proof"] or "")
self.assertEqual(record["prior_session_pid"], result["evidence"]["prior_session_pid"])
self.assertEqual(record["replacement_session_pid"], os.getpid())
class TestDescendantEvidenceReachesPublicationGates(unittest.TestCase):
def _descendant_assessment(self):
return assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
def test_owning_pr_evidence_carries_accepted_head(self):
token = issue_lock_recovery.owning_pr_recovery_evidence(
self._descendant_assessment()
)
self.assertIsNotNone(token)
assert token is not None
self.assertEqual(token["head_sha"], RECORDED)
self.assertEqual(token["accepted_head"], DESCENDANT)
self.assertEqual(token["recorded_head"], RECORDED)
self.assertEqual(
token["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
def test_persisted_lock_rebuilds_owning_pr_evidence(self):
assessment = self._descendant_assessment()
record = issue_lock_recovery.build_recovery_record(
assessment, recovered_at="2026-07-20T00:00:00Z"
)
lock = make_lock(dead_session_recovery=record)
token = issue_lock_recovery.recovered_owning_pr_from_lock(lock)
self.assertIsNotNone(token)
assert token is not None
self.assertEqual(token["pr_number"], PR_NUMBER)
self.assertEqual(token["head_sha"], RECORDED)
self.assertEqual(token["accepted_head"], DESCENDANT)
def test_duplicate_gate_accepts_pr_at_recorded_or_accepted_head(self):
token = issue_lock_recovery.owning_pr_recovery_evidence(
self._descendant_assessment()
)
for live_sha in (RECORDED, DESCENDANT):
with self.subTest(live_sha=live_sha):
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[
{
"number": PR_NUMBER,
"title": f"Closes #{ISSUE}",
"body": f"Closes #{ISSUE}",
"head": {"ref": BRANCH, "sha": live_sha},
}
],
branch_names=[BRANCH],
claim_entry={"status": "unclaimed"},
locked_branch=BRANCH,
phase=issue_work_duplicate_gate.PHASE_COMMIT,
recovered_owning_pr=token,
)
self.assertFalse(gate["block"], gate)
self.assertTrue(gate["owning_pr_recovery_exempted"])
def test_duplicate_gate_still_rejects_foreign_head(self):
token = issue_lock_recovery.owning_pr_recovery_evidence(
self._descendant_assessment()
)
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[
{
"number": PR_NUMBER,
"title": f"Closes #{ISSUE}",
"body": f"Closes #{ISSUE}",
"head": {"ref": BRANCH, "sha": DIVERGED},
}
],
branch_names=[BRANCH],
claim_entry={"status": "unclaimed"},
locked_branch=BRANCH,
phase=issue_work_duplicate_gate.PHASE_COMMIT,
recovered_owning_pr=token,
)
self.assertTrue(gate["block"])
self.assertFalse(gate["owning_pr_recovery_exempted"])
class TestDirtyDescendantRejected(unittest.TestCase):
def test_dirty_descendant_refused(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
porcelain_status=" M issue_lock_recovery.py\n",
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(any("dirty" in r.lower() for r in result["reasons"]))
class TestDivergedAndBehindRejected(unittest.TestCase):
def test_diverged_head_refused(self):
result = assess(
head_sha=DIVERGED,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(
ancestor=RECORDED,
descendant=DIVERGED,
is_strict=False,
reasons=[
f"local head {DIVERGED} does not descend from recorded head "
f"{RECORDED}"
],
),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertIsNone(result["evidence"].get("head_relation"))
self.assertTrue(
any("does not match remote" in r for r in result["reasons"]),
result["reasons"],
)
def test_local_behind_recorded_refused(self):
# merge-base --is-ancestor RECORDED BEHIND is false when BEHIND is ancestor.
result = assess(
head_sha=BEHIND,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(
ancestor=RECORDED,
descendant=BEHIND,
is_strict=False,
reasons=[
f"local head {BEHIND} does not descend from recorded head "
f"{RECORDED}"
],
),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("does not match remote" in r or "not a strict descendant" in r
for r in result["reasons"]),
result["reasons"],
)
class TestUnrelatedAndMalformedAncestryRejected(unittest.TestCase):
def test_missing_ancestry_observation_fails_closed(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=None,
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("ancestry" in r.lower() for r in result["reasons"]),
result["reasons"],
)
def test_mismatched_probe_pair_fails_closed(self):
# Observation for a different commit pair must not authorize this pair.
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(ancestor=DIVERGED, descendant=DESCENDANT),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("not the heads under assessment" in r for r in result["reasons"]),
result["reasons"],
)
def test_rewritten_recorded_head_fails_closed(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(ancestor_present=False, is_strict=False),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("no longer reachable" in r or "rewritten" in r
for r in result["reasons"]),
result["reasons"],
)
def test_failed_probe_fails_closed(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(
probe_ok=False,
is_strict=False,
reasons=["ancestry probe failed with exit 128; ancestry unproven"],
),
)
self.assertFalse(result["recovery_sanctioned"])
def test_pr_head_not_equal_to_recorded_blocks_descendant(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=DIVERGED,
head_ancestry=ancestry_ok(),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("open PR" in r and "does not match" in r for r in result["reasons"]),
result["reasons"],
)
class TestLiveOwnerStillRejected(unittest.TestCase):
def test_live_prior_pid_refused_even_with_descendant_proof(self):
result = assess(
lock=make_lock(session_pid=os.getpid()),
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("still alive" in r or "live" in r.lower() for r in result["reasons"]),
result["reasons"],
)
class TestDiagnosticsIdentifyDisposition(unittest.TestCase):
def test_equal_disposition_named(self):
result = assess()
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_EQUAL,
)
def test_descendant_disposition_named(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
def test_rejected_divergence_has_no_accepted_relation(self):
result = assess(
head_sha=DIVERGED,
remote_head_sha=RECORDED,
head_ancestry=None,
)
self.assertIsNone(result["evidence"].get("head_relation"))
message = issue_lock_recovery.format_recovery_refusal(result)
self.assertIn("fail closed", message)
self.assertIn("does not match remote", message)
class TestReadHeadAncestryRealGit(unittest.TestCase):
def _git(self, repo: str, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", repo, *args],
capture_output=True,
text=True,
check=True,
)
def _init_repo_with_chain(self) -> tuple[str, str, str, str]:
"""Return (repo, parent_sha, child_sha, sibling_sha)."""
repo = tempfile.mkdtemp(prefix="issue-768-ancestry-")
self._git(repo, "init")
self._git(repo, "config", "user.email", "[email protected]")
self._git(repo, "config", "user.name", "Test")
path = Path(repo) / "f.txt"
path.write_text("one\n")
self._git(repo, "add", "f.txt")
self._git(repo, "commit", "-m", "parent")
parent = self._git(repo, "rev-parse", "HEAD").stdout.strip()
path.write_text("two\n")
self._git(repo, "add", "f.txt")
self._git(repo, "commit", "-m", "child")
child = self._git(repo, "rev-parse", "HEAD").stdout.strip()
# Divergent sibling: branch from parent, then unique commit.
self._git(repo, "checkout", "-B", "side", parent)
path.write_text("side\n")
self._git(repo, "add", "f.txt")
self._git(repo, "commit", "-m", "sibling")
sibling = self._git(repo, "rev-parse", "HEAD").stdout.strip()
self._git(repo, "checkout", "-B", "main", child)
return repo, parent, child, sibling
def test_strict_descendant_observation(self):
repo, parent, child, _sibling = self._init_repo_with_chain()
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha=parent, descendant_sha=child
)
self.assertTrue(obs["probe_ok"])
self.assertTrue(obs["ancestor_present"])
self.assertTrue(obs["is_ancestor"])
self.assertTrue(obs["is_strict_descendant"])
self.assertEqual(obs["ancestor_sha"], parent)
self.assertEqual(obs["descendant_sha"], child)
def test_equal_heads_not_strict_descendant(self):
repo, parent, _child, _sibling = self._init_repo_with_chain()
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha=parent, descendant_sha=parent
)
self.assertTrue(obs["probe_ok"])
self.assertTrue(obs["is_ancestor"])
self.assertFalse(obs["is_strict_descendant"])
def test_diverged_not_ancestor(self):
repo, _parent, child, sibling = self._init_repo_with_chain()
# child and sibling share a parent but neither descends from the other.
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha=child, descendant_sha=sibling
)
self.assertTrue(obs["probe_ok"])
self.assertFalse(obs["is_ancestor"])
self.assertFalse(obs["is_strict_descendant"])
def test_missing_sha_fails_closed(self):
repo, _parent, child, _ = self._init_repo_with_chain()
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha="0" * 40, descendant_sha=child
)
self.assertFalse(obs["probe_ok"])
self.assertFalse(obs["ancestor_present"])
def test_end_to_end_real_git_descendant_recovery(self):
repo, parent, child, _sibling = self._init_repo_with_chain()
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha=parent, descendant_sha=child
)
result = assess(
worktree_path=repo,
head_sha=child,
remote_head_sha=parent,
pr_head_sha=parent,
head_ancestry=obs,
lock=make_lock(worktree_path=repo),
)
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+635
View File
@@ -0,0 +1,635 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mutation_profile_fixture import install_deterministic_remote_urls # noqa: E402
install_deterministic_remote_urls()
"""#781: sanctioned issue title/body editing, and the documentation drift guard.
Two defects are covered here. The first is that no MCP path could edit an issue
title or body at all, so an authorized correction had to be recorded as a
comment. The second is why nobody noticed: documentation named a tool that was
never registered, and nothing compared the two lists.
"""
import glob
import json
import os
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import anti_stomp_preflight # noqa: E402
import edit_issue # noqa: E402
import mcp_server # noqa: E402
import mcp_tool_inventory # noqa: E402
import task_capability_map # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"edit-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
"forbidden_operations": [],
"allowed_repositories": [
"Scaled-Tech-Consulting/Gitea-Tools",
"Example-Org/Example-Repo",
"913443/eAgenda",
],
"execution_profile": "edit-author",
},
"read-only-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "reader-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read"],
"forbidden_operations": ["gitea.issue.comment"],
"allowed_repositories": [
"Scaled-Tech-Consulting/Gitea-Tools",
"Example-Org/Example-Repo",
"913443/eAgenda",
],
"execution_profile": "read-only-author",
},
},
"rules": {"allow_runtime_switching": False},
}
ISSUE_NUMBER = 9
ORIGINAL_TITLE = "fix(mcp): original title"
ORIGINAL_BODY = "Original body.\n"
NEW_TITLE = "fix(mcp): corrected title"
NEW_BODY = "Corrected body.\n"
def _registered_tool_names() -> set[str]:
manager = mcp_server.mcp._tool_manager
return set((getattr(manager, "_tools", None) or {}).keys())
# ---------------------------------------------------------------------------
# Rule: request validation
# ---------------------------------------------------------------------------
class TestValidateEditRequest(unittest.TestCase):
def test_no_field_is_rejected(self):
with self.assertRaises(ValueError) as ctx:
edit_issue.validate_edit_request()
self.assertIn("At least one field", str(ctx.exception))
def test_blank_title_is_rejected(self):
with self.assertRaises(ValueError) as ctx:
edit_issue.validate_edit_request(title=" ")
self.assertIn("cannot be blank", str(ctx.exception))
def test_non_string_title_is_rejected(self):
with self.assertRaises(ValueError):
edit_issue.validate_edit_request(title=42)
def test_non_string_body_is_rejected(self):
with self.assertRaises(ValueError):
edit_issue.validate_edit_request(body=["not", "a", "string"])
def test_empty_body_is_a_legitimate_edit(self):
self.assertEqual(edit_issue.validate_edit_request(body=""), {"body": ""})
# ---------------------------------------------------------------------------
# Rule: planning against the pre-image
# ---------------------------------------------------------------------------
class TestPlanIssueEdit(unittest.TestCase):
def _current(self, **overrides):
issue = {
"number": ISSUE_NUMBER,
"title": ORIGINAL_TITLE,
"body": ORIGINAL_BODY,
"state": "open",
"labels": [{"name": "type:bug"}, {"name": "mcp"}],
"assignees": [{"login": "author-user"}],
"milestone": {"title": "v1.2.0"},
}
issue.update(overrides)
return issue
def test_title_only_sends_only_the_title(self):
plan = edit_issue.plan_issue_edit(self._current(), title=NEW_TITLE)
self.assertEqual(plan["payload"], {"title": NEW_TITLE})
self.assertEqual(plan["requested_fields"], ["title"])
self.assertFalse(plan["no_op"])
def test_body_only_sends_only_the_body(self):
plan = edit_issue.plan_issue_edit(self._current(), body=NEW_BODY)
self.assertEqual(plan["payload"], {"body": NEW_BODY})
def test_combined_edit_sends_both(self):
plan = edit_issue.plan_issue_edit(
self._current(), title=NEW_TITLE, body=NEW_BODY
)
self.assertEqual(plan["payload"], {"title": NEW_TITLE, "body": NEW_BODY})
self.assertEqual(plan["requested_fields"], ["body", "title"])
def test_identical_content_is_an_explicit_no_op(self):
plan = edit_issue.plan_issue_edit(
self._current(), title=ORIGINAL_TITLE, body=ORIGINAL_BODY
)
self.assertTrue(plan["no_op"])
self.assertEqual(plan["payload"], {})
self.assertTrue(plan["reasons"])
self.assertTrue(plan["safe_next_action"])
def test_partially_unchanged_request_sends_only_the_difference(self):
plan = edit_issue.plan_issue_edit(
self._current(), title=ORIGINAL_TITLE, body=NEW_BODY
)
self.assertFalse(plan["no_op"])
self.assertEqual(plan["payload"], {"body": NEW_BODY})
self.assertEqual(plan["unchanged_fields"], ["title"])
def test_missing_body_is_compared_as_empty(self):
current = self._current()
current.pop("body")
plan = edit_issue.plan_issue_edit(current, body="")
self.assertTrue(plan["no_op"])
def test_preserved_snapshot_captures_untouched_fields(self):
plan = edit_issue.plan_issue_edit(self._current(), title=NEW_TITLE)
before = plan["preserved_before"]
self.assertEqual(before["state"], "open")
self.assertEqual(before["labels"], ["type:bug", "mcp"])
self.assertEqual(before["assignees"], ["author-user"])
self.assertEqual(before["milestone"], "v1.2.0")
# ---------------------------------------------------------------------------
# Rule: pull requests are refused
# ---------------------------------------------------------------------------
class TestAssessIssueTarget(unittest.TestCase):
def test_issue_is_accepted(self):
target = edit_issue.assess_issue_target(
{"number": 9, "title": "t"}, issue_number=9
)
self.assertTrue(target["is_issue"])
self.assertEqual(target["reasons"], [])
def test_pull_request_is_refused_with_a_next_action(self):
target = edit_issue.assess_issue_target(
{"number": 9, "pull_request": {"merged": False}}, issue_number=9
)
self.assertFalse(target["is_issue"])
self.assertTrue(target["is_pull_request"])
self.assertIn("gitea_edit_pr", target["safe_next_action"])
# ---------------------------------------------------------------------------
# Rule: read-after-write verification
# ---------------------------------------------------------------------------
class TestVerifyIssueEdit(unittest.TestCase):
def _plan(self, **kwargs):
current = {
"number": ISSUE_NUMBER,
"title": ORIGINAL_TITLE,
"body": ORIGINAL_BODY,
"state": "open",
"labels": [{"name": "type:bug"}],
"assignees": [],
"milestone": None,
}
return edit_issue.plan_issue_edit(current, **kwargs)
def test_applied_content_verifies(self):
plan = self._plan(title=NEW_TITLE)
observed = {
"title": NEW_TITLE,
"body": ORIGINAL_BODY,
"state": "open",
"labels": [{"name": "type:bug"}],
"assignees": [],
"milestone": None,
}
result = edit_issue.verify_issue_edit(observed, plan=plan)
self.assertTrue(result["verified"])
self.assertTrue(result["preserved_intact"])
self.assertEqual(result["applied"], {"title": NEW_TITLE})
def test_unapplied_content_fails_closed(self):
plan = self._plan(title=NEW_TITLE)
observed = {
"title": ORIGINAL_TITLE,
"state": "open",
"labels": [{"name": "type:bug"}],
}
result = edit_issue.verify_issue_edit(observed, plan=plan)
self.assertFalse(result["verified"])
self.assertEqual(result["mismatches"][0]["field"], "title")
self.assertTrue(result["safe_next_action"])
def test_dropped_label_fails_closed(self):
plan = self._plan(title=NEW_TITLE)
observed = {"title": NEW_TITLE, "state": "open", "labels": []}
result = edit_issue.verify_issue_edit(observed, plan=plan)
self.assertFalse(result["verified"])
self.assertFalse(result["preserved_intact"])
self.assertEqual(result["preserved_changed"][0]["field"], "labels")
def test_changed_state_fails_closed(self):
plan = self._plan(body=NEW_BODY)
observed = {
"body": NEW_BODY,
"state": "closed",
"labels": [{"name": "type:bug"}],
}
result = edit_issue.verify_issue_edit(observed, plan=plan)
self.assertFalse(result["verified"])
self.assertEqual(result["preserved_changed"][0]["field"], "state")
# ---------------------------------------------------------------------------
# Tool: gitea_edit_issue against a fake Gitea
# ---------------------------------------------------------------------------
class _EditIssueToolHarness(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(
mcp_server.REMOTES,
{
"prgs": {
"host": "gitea.example.com",
"org": "Example-Org",
"repo": "Example-Repo",
}
},
)
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self.issue = {
"number": ISSUE_NUMBER,
"title": ORIGINAL_TITLE,
"body": ORIGINAL_BODY,
"state": "open",
"labels": [{"name": "type:bug"}, {"name": "mcp"}],
"assignees": [{"login": "author-user"}],
"milestone": {"title": "v1.2.0"},
"html_url": "https://gitea.example.com/Example-Org/Example-Repo/issues/9",
}
self.calls: list[tuple[str, str]] = []
self.patched_payloads: list[dict] = []
patch("gitea_audit.audit_enabled", return_value=False).start()
patch("mcp_server.get_auth_header", return_value="token author-pass").start()
patch("mcp_server.api_request", side_effect=self._api).start()
self.addCleanup(patch.stopall)
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
self._dir.cleanup()
def _api(self, method, url, auth, payload=None):
self.calls.append((method, url))
if url.endswith("/user"):
return {"login": "author-user"}
if "/issues/" in url:
if method == "GET":
return dict(self.issue)
if method == "PATCH":
self.patched_payloads.append(dict(payload or {}))
self.issue.update(payload or {})
return dict(self.issue)
raise AssertionError(f"unexpected API call: {method} {url}")
def _env(self, profile: str = "edit-author") -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"PYTEST_CURRENT_TEST": os.environ.get(
"PYTEST_CURRENT_TEST", "issue_781_edit_issue"
),
}
def _edit(self, profile: str = "edit-author", **kwargs):
with patch.dict(os.environ, self._env(profile), clear=True):
return mcp_server.gitea_edit_issue(
issue_number=ISSUE_NUMBER, remote="prgs", **kwargs
)
def _patch_methods(self) -> list[str]:
return [method for method, _url in self.calls if method == "PATCH"]
class TestEditIssueSucceeds(_EditIssueToolHarness):
def test_title_only_edit(self):
result = self._edit(title=NEW_TITLE)
self.assertTrue(result["success"])
self.assertTrue(result["verified"])
self.assertEqual(result["changed_fields"], ["title"])
self.assertEqual(self.patched_payloads, [{"title": NEW_TITLE}])
self.assertEqual(self.issue["title"], NEW_TITLE)
self.assertEqual(self.issue["body"], ORIGINAL_BODY)
def test_body_only_edit(self):
result = self._edit(body=NEW_BODY)
self.assertTrue(result["success"])
self.assertEqual(self.patched_payloads, [{"body": NEW_BODY}])
self.assertEqual(self.issue["title"], ORIGINAL_TITLE)
self.assertEqual(self.issue["body"], NEW_BODY)
def test_combined_edit(self):
result = self._edit(title=NEW_TITLE, body=NEW_BODY)
self.assertTrue(result["success"])
self.assertEqual(
self.patched_payloads, [{"title": NEW_TITLE, "body": NEW_BODY}]
)
self.assertEqual(result["applied"], {"title": NEW_TITLE, "body": NEW_BODY})
def test_body_can_be_cleared(self):
result = self._edit(body="")
self.assertTrue(result["success"])
self.assertEqual(self.issue["body"], "")
def test_targets_the_issue_endpoint_never_the_pull_endpoint(self):
self._edit(title=NEW_TITLE)
patched = [url for method, url in self.calls if method == "PATCH"]
self.assertTrue(patched)
for url in patched:
self.assertIn("/issues/", url)
self.assertNotIn("/pulls/", url)
def test_labels_state_assignee_and_milestone_are_provably_unchanged(self):
result = self._edit(title=NEW_TITLE)
proof = result["read_after_write"]
self.assertTrue(proof["preserved_intact"])
self.assertEqual(proof["preserved_before"], proof["preserved_after"])
self.assertEqual(proof["preserved_after"]["labels"], ["type:bug", "mcp"])
self.assertEqual(proof["preserved_after"]["state"], "open")
self.assertEqual(proof["preserved_after"]["assignees"], ["author-user"])
self.assertEqual(proof["preserved_after"]["milestone"], "v1.2.0")
def test_read_after_write_re_reads_the_issue(self):
self._edit(title=NEW_TITLE)
issue_calls = [method for method, url in self.calls if "/issues/" in url]
self.assertEqual(issue_calls, ["GET", "PATCH", "GET"])
class TestEditIssueFailsClosed(_EditIssueToolHarness):
def test_no_op_request_is_rejected_without_a_patch(self):
result = self._edit(title=ORIGINAL_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertTrue(result["no_op"])
self.assertEqual(self._patch_methods(), [])
self.assertTrue(result["reasons"])
self.assertTrue(result["safe_next_action"])
def test_invalid_request_raises_before_any_api_call(self):
with self.assertRaises(ValueError):
self._edit()
self.assertEqual(self.calls, [])
def test_blank_title_raises_before_any_api_call(self):
with self.assertRaises(ValueError):
self._edit(title=" ")
self.assertEqual(self.calls, [])
def test_authorization_failure_blocks_before_any_api_call(self):
result = self._edit(profile="read-only-author", title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("permission_report", result)
self.assertEqual(
result["permission_report"]["missing_permission"],
task_capability_map.required_permission("edit_issue"),
)
self.assertEqual(self.calls, [])
def test_pull_request_target_is_refused_without_a_patch(self):
self.issue["pull_request"] = {"merged": False}
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertEqual(self._patch_methods(), [])
self.assertIn("gitea_edit_pr", result["safe_next_action"])
def test_pre_read_transport_error_is_reported(self):
def boom(method, url, auth, payload=None):
raise RuntimeError("connection reset by peer")
with patch("mcp_server.api_request", side_effect=boom):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertTrue(result["reasons"])
self.assertTrue(result["safe_next_action"])
def test_patch_transport_error_is_reported_not_swallowed(self):
def flaky(method, url, auth, payload=None):
if method == "PATCH":
raise RuntimeError("gitea exploded")
return self._api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=flaky):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("issue edit failed", result["reasons"][0])
self.assertEqual(self.issue["title"], ORIGINAL_TITLE)
def test_read_back_transport_error_reports_an_unverified_edit(self):
state = {"gets": 0}
def flaky(method, url, auth, payload=None):
if method == "GET" and "/issues/" in url:
state["gets"] += 1
if state["gets"] > 1:
raise RuntimeError("read timed out")
return self._api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=flaky):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertTrue(result["performed"])
self.assertFalse(result["verified"])
self.assertTrue(result["safe_next_action"])
def test_unapplied_edit_fails_verification(self):
def sticky(method, url, auth, payload=None):
if method == "PATCH":
self.calls.append((method, url))
# Report success but store nothing.
return dict(self.issue)
return self._api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=sticky):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertTrue(result["performed"])
self.assertFalse(result["verified"])
self.assertEqual(
result["read_after_write"]["mismatches"][0]["field"], "title"
)
def test_edit_that_drops_a_label_fails_verification(self):
def label_eating(method, url, auth, payload=None):
if method == "PATCH":
self.calls.append((method, url))
self.issue.update(payload or {})
self.issue["labels"] = []
return dict(self.issue)
return self._api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=label_eating):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["read_after_write"]["preserved_intact"])
# ---------------------------------------------------------------------------
# Registration and gate wiring
# ---------------------------------------------------------------------------
class TestEditIssueRegistrationAndGates(unittest.TestCase):
def test_tool_is_registered(self):
self.assertIn("gitea_edit_issue", _registered_tool_names())
def test_resolver_task_exists_with_author_role(self):
self.assertEqual(
task_capability_map.required_permission("edit_issue"),
"gitea.issue.comment",
)
self.assertEqual(task_capability_map.required_role("edit_issue"), "author")
def test_tool_gate_matches_the_resolver_task(self):
self.assertEqual(
task_capability_map.ISSUE_MUTATION_TOOL_TASKS["gitea_edit_issue"],
"edit_issue",
)
self.assertEqual(
task_capability_map.tool_required_permission("gitea_edit_issue"),
task_capability_map.required_permission("edit_issue"),
)
def test_declared_as_an_anti_stomp_mutation_task(self):
self.assertIn("edit_issue", anti_stomp_preflight.MUTATION_TASKS)
def test_edit_pr_remains_pull_request_only(self):
import inspect
params = inspect.signature(mcp_server.gitea_edit_pr).parameters
self.assertIn("pr_number", params)
self.assertNotIn("issue_number", params)
def test_edit_issue_cannot_change_state_or_labels(self):
import inspect
params = inspect.signature(mcp_server.gitea_edit_issue).parameters
self.assertEqual(
[name for name in params if name in ("title", "body")],
["title", "body"],
)
for forbidden in ("state", "labels", "assignee", "assignees", "milestone"):
self.assertNotIn(forbidden, params)
# ---------------------------------------------------------------------------
# The drift guard itself
# ---------------------------------------------------------------------------
class TestInventoryDriftRule(unittest.TestCase):
def test_missing_markers_fail_closed(self):
with self.assertRaises(ValueError):
mcp_tool_inventory.parse_documented_inventory("no markers here")
def test_documented_but_unregistered_is_drift(self):
result = mcp_tool_inventory.assess_inventory_drift(
["gitea_edit_issue", "gitea_view_issue"], ["gitea_view_issue"]
)
self.assertFalse(result["in_sync"])
self.assertEqual(result["documented_not_registered"], ["gitea_edit_issue"])
self.assertTrue(result["safe_next_action"])
def test_registered_but_undocumented_is_drift(self):
result = mcp_tool_inventory.assess_inventory_drift(
["gitea_view_issue"], ["gitea_view_issue", "gitea_edit_issue"]
)
self.assertFalse(result["in_sync"])
self.assertEqual(result["registered_not_documented"], ["gitea_edit_issue"])
def test_unsorted_inventory_is_drift(self):
result = mcp_tool_inventory.assess_inventory_drift(
["gitea_view_issue", "gitea_edit_issue"],
["gitea_view_issue", "gitea_edit_issue"],
)
self.assertFalse(result["in_sync"])
self.assertFalse(result["sorted"])
def test_module_names_are_not_treated_as_tools(self):
self.assertFalse(mcp_tool_inventory.looks_like_tool_name("gitea_auth"))
self.assertTrue(mcp_tool_inventory.looks_like_tool_name("gitea_view_issue"))
def test_unregistered_doc_reference_is_reported(self):
result = mcp_tool_inventory.assess_doc_references(
{"skills/example.md": {"gitea_edit_issue"}}, ["gitea_view_issue"]
)
self.assertFalse(result["clean"])
self.assertEqual(result["unregistered"][0]["tool"], "gitea_edit_issue")
def test_rendered_block_round_trips(self):
block = mcp_tool_inventory.render_inventory_block(
["gitea_view_issue", "gitea_edit_issue"]
)
self.assertEqual(
mcp_tool_inventory.parse_documented_inventory(block),
["gitea_edit_issue", "gitea_view_issue"],
)
class TestDocumentationMatchesRegistry(unittest.TestCase):
"""The live guard: docs and the registry must not drift apart."""
def test_documented_inventory_equals_registered_tools(self):
doc = REPO_ROOT / mcp_tool_inventory.INVENTORY_DOC_PATH
self.assertTrue(doc.exists(), f"{doc} is missing")
documented = mcp_tool_inventory.parse_documented_inventory(
doc.read_text(encoding="utf-8")
)
result = mcp_tool_inventory.assess_inventory_drift(
documented, _registered_tool_names()
)
self.assertTrue(result["in_sync"], "; ".join(result["reasons"]))
def test_every_tool_named_in_the_skills_is_registered(self):
references: dict[str, set[str]] = {}
pattern = str(REPO_ROOT / "skills" / "**" / "*.md")
paths = glob.glob(pattern, recursive=True)
self.assertTrue(paths, "no skill documents found to check")
for path in paths:
text = Path(path).read_text(encoding="utf-8")
names = mcp_tool_inventory.extract_tool_references(text)
if names:
references[str(Path(path).relative_to(REPO_ROOT))] = names
result = mcp_tool_inventory.assess_doc_references(
references, _registered_tool_names()
)
self.assertTrue(result["clean"], "; ".join(result["reasons"]))
if __name__ == "__main__":
unittest.main()
+722
View File
@@ -0,0 +1,722 @@
"""Tests for durable dependency edges (#784, umbrella #628 scope item 6)."""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import allocator_dependencies
import dependency_graph
import gitea_mcp_server as srv
import mcp_tool_inventory
from control_plane_db import SCHEMA_VERSION, ControlPlaneDB, ControlPlaneError
ISSUE = dependency_graph.WORK_KIND_ISSUE
PR = dependency_graph.WORK_KIND_PR
EDGE_BLOCKED = dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
# Schema as it stood before this change, used to prove a real v3 → v4 migration
# rather than a fresh-database creation dressed up as one.
_V3_SCHEMA = """
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
role TEXT NOT NULL,
profile TEXT,
namespace TEXT,
pid INTEGER,
started_at TEXT NOT NULL,
last_heartbeat_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE work_items (
work_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
remote TEXT NOT NULL,
org TEXT NOT NULL,
repo TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('issue', 'pr')),
number INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 0,
current_head_sha TEXT,
updated_at TEXT NOT NULL,
UNIQUE (remote, org, repo, kind, number)
);
CREATE TABLE leases (
lease_id TEXT PRIMARY KEY,
work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
role TEXT NOT NULL,
phase TEXT NOT NULL DEFAULT 'claimed',
expires_at TEXT NOT NULL,
heartbeat_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE assignments (
assignment_id TEXT PRIMARY KEY,
work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
lease_id TEXT NOT NULL REFERENCES leases(lease_id),
allowed_actions TEXT NOT NULL,
forbidden_actions TEXT NOT NULL,
expected_head_sha TEXT,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL
);
CREATE TABLE terminal_locks (
terminal_lock_id INTEGER PRIMARY KEY AUTOINCREMENT,
remote TEXT NOT NULL,
org TEXT NOT NULL,
repo TEXT NOT NULL,
terminal_pr INTEGER NOT NULL,
review_id TEXT,
decision TEXT,
status TEXT NOT NULL DEFAULT 'active',
cleanup_state TEXT,
created_at TEXT NOT NULL,
UNIQUE (remote, org, repo, terminal_pr)
);
CREATE TABLE events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
work_item_id INTEGER REFERENCES work_items(work_item_id),
event_type TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE incident_links (
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
provider_base_url TEXT NOT NULL DEFAULT '',
provider_org TEXT NOT NULL DEFAULT '',
provider_project TEXT NOT NULL DEFAULT '',
provider_issue_id TEXT NOT NULL,
provider_short_id TEXT,
provider_permalink TEXT,
fingerprint TEXT,
gitea_org TEXT NOT NULL,
gitea_repo TEXT NOT NULL,
gitea_issue_number INTEGER NOT NULL,
linked_pr_numbers TEXT,
first_seen TEXT,
last_seen TEXT,
event_count INTEGER,
status TEXT NOT NULL DEFAULT 'open',
release_resolved_at TEXT,
last_sync_at TEXT,
UNIQUE (provider, provider_base_url, provider_org, provider_project,
provider_issue_id)
);
"""
def _edge_kwargs(**overrides):
base = {
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"source_kind": ISSUE,
"source_number": 784,
"target_kind": ISSUE,
"target_number": 628,
"edge_type": EDGE_BLOCKED,
"state": dependency_graph.STATE_UNMET,
}
base.update(overrides)
return base
class VocabularyTest(unittest.TestCase):
"""AC4, AC5: the edge vocabulary is complete and fails closed."""
def test_all_seven_umbrella_relationship_types_exist(self) -> None:
self.assertEqual(len(dependency_graph.EDGE_TYPES), 7)
for edge_type in (
dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
dependency_graph.EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
dependency_graph.EDGE_MERGE_WAITING_FOR_APPROVAL,
dependency_graph.EDGE_RECONCILIATION_WAITING_FOR_MERGE,
dependency_graph.EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
dependency_graph.EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
):
self.assertIn(edge_type, dependency_graph.EDGE_TYPES)
blocking, completion = dependency_graph.default_conditions(edge_type)
self.assertTrue(blocking and completion)
def test_states_match_the_resolver_partitions(self) -> None:
self.assertEqual(
dependency_graph.EDGE_STATES,
frozenset({"unmet", "met", "unavailable"}),
)
def test_unknown_edge_type_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
dependency_graph.normalize_edge_type("waits_for_vibes")
def test_unknown_state_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeStateError):
dependency_graph.normalize_edge_state("probably_fine")
def test_non_work_endpoint_kind_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
dependency_graph.normalize_work_kind("incident")
def test_evidence_sanitization_strips_credentials_and_urls(self) -> None:
clean = dependency_graph.sanitize_evidence(
{
"token": "abc123",
"authorization": "Bearer xyz",
"note": "fetched from https://gitea.example.invalid/api/v1/x",
"nested": [{"api_key": "k"}, "plain"],
"observed_state": "closed",
}
)
self.assertEqual(clean["token"], dependency_graph.REDACTED)
self.assertEqual(clean["authorization"], dependency_graph.REDACTED)
self.assertNotIn("https://", clean["note"])
self.assertEqual(clean["nested"][0]["api_key"], dependency_graph.REDACTED)
self.assertEqual(clean["observed_state"], "closed")
class SchemaTest(unittest.TestCase):
"""AC1-AC3: schema creation, migration, and idempotence."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
def tearDown(self) -> None:
self._tmp.cleanup()
def _tables(self) -> set[str]:
conn = sqlite3.connect(self.db_path)
try:
return {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
finally:
conn.close()
def _schema_version(self) -> str:
conn = sqlite3.connect(self.db_path)
try:
row = conn.execute(
"SELECT value FROM schema_meta WHERE key = 'schema_version'"
).fetchone()
finally:
conn.close()
return str(row[0]) if row else ""
def test_fresh_database_is_v4_with_the_edge_table(self) -> None:
ControlPlaneDB(self.db_path)
self.assertEqual(SCHEMA_VERSION, 4)
self.assertEqual(self._schema_version(), "4")
self.assertIn("dependency_edges", self._tables())
def _seed_v3(self) -> None:
conn = sqlite3.connect(self.db_path)
try:
conn.executescript(_V3_SCHEMA)
conn.execute(
"INSERT INTO schema_meta(key, value) VALUES ('schema_version', '3')"
)
conn.execute(
"""
INSERT INTO work_items(
remote, org, repo, kind, number, state, priority, updated_at
) VALUES ('prgs', 'O', 'R', 'issue', 601, 'open', 20,
'2026-07-01T00:00:00Z')
"""
)
conn.execute(
"""
INSERT INTO sessions(session_id, role, started_at, last_heartbeat_at)
VALUES ('legacy-session', 'author', '2026-07-01T00:00:00Z',
'2026-07-01T00:00:00Z')
"""
)
conn.execute(
"""
INSERT INTO events(work_item_id, event_type, message, created_at)
VALUES (1, 'legacy', 'kept', '2026-07-01T00:00:00Z')
"""
)
conn.commit()
finally:
conn.close()
def test_v3_database_migrates_in_place_without_losing_rows(self) -> None:
self._seed_v3()
self.assertNotIn("dependency_edges", self._tables())
ControlPlaneDB(self.db_path)
self.assertEqual(self._schema_version(), "4")
self.assertIn("dependency_edges", self._tables())
conn = sqlite3.connect(self.db_path)
try:
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0], 1
)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0], 1
)
self.assertEqual(
conn.execute(
"SELECT message FROM events WHERE event_type = 'legacy'"
).fetchone()[0],
"kept",
)
for table in ("leases", "assignments", "terminal_locks", "incident_links"):
conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
finally:
conn.close()
def test_migration_is_idempotent(self) -> None:
self._seed_v3()
ControlPlaneDB(self.db_path)
db = ControlPlaneDB(self.db_path) # second open re-runs the migration
ControlPlaneDB(self.db_path)
self.assertEqual(self._schema_version(), "4")
conn = sqlite3.connect(self.db_path)
try:
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'dependency_edges'"
).fetchall()
finally:
conn.close()
self.assertEqual(len(tables), 1)
self.assertEqual(db.list_dependency_edges(), [])
class EdgePersistenceTest(unittest.TestCase):
"""AC5-AC10: storage, uniqueness, lookup, scope, audit, redaction."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _events(self) -> list[tuple[str, str]]:
conn = sqlite3.connect(self.db.db_path)
try:
return [
(str(row[0]), str(row[1]))
for row in conn.execute(
"SELECT event_type, message FROM events"
).fetchall()
]
finally:
conn.close()
def test_invalid_values_write_nothing(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
self.db.upsert_dependency_edge(**_edge_kwargs(edge_type="nonsense"))
with self.assertRaises(dependency_graph.InvalidEdgeStateError):
self.db.upsert_dependency_edge(**_edge_kwargs(state="maybe"))
with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
self.db.upsert_dependency_edge(**_edge_kwargs(target_kind="incident"))
self.assertEqual(self.db.list_dependency_edges(), [])
def test_stored_edge_carries_the_full_contract(self) -> None:
edge = self.db.upsert_dependency_edge(
**_edge_kwargs(evidence={"observed_state": "not_closed"})
)
self.assertEqual(edge["source_number"], 784)
self.assertEqual(edge["target_number"], 628)
self.assertEqual(edge["edge_type"], EDGE_BLOCKED)
self.assertEqual(edge["state"], "unmet")
self.assertEqual(edge["blocking_condition"], "target issue is not closed")
self.assertEqual(edge["completion_condition"], "target issue is closed")
self.assertEqual(edge["evidence"], {"observed_state": "not_closed"})
self.assertTrue(edge["created_at"])
self.assertTrue(edge["last_observed_at"])
def test_repeated_upsert_updates_one_row(self) -> None:
first = self.db.upsert_dependency_edge(**_edge_kwargs())
second = self.db.upsert_dependency_edge(
**_edge_kwargs(state="met", evidence={"observed_state": "closed"})
)
self.assertEqual(first["edge_id"], second["edge_id"])
edges = self.db.list_dependency_edges()
self.assertEqual(len(edges), 1)
self.assertEqual(edges[0]["state"], "met")
self.assertEqual(edges[0]["evidence"], {"observed_state": "closed"})
def test_upsert_state_change_is_audited(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs())
self.db.upsert_dependency_edge(**_edge_kwargs()) # unchanged: no event
self.assertEqual(self._events(), [])
self.db.upsert_dependency_edge(**_edge_kwargs(state="met"))
events = self._events()
self.assertEqual(len(events), 1)
self.assertEqual(events[0][0], "dependency_edge_state_change")
self.assertIn("unmet -> met", events[0][1])
def test_reverse_lookup_finds_every_waiter(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs(source_number=784))
self.db.upsert_dependency_edge(**_edge_kwargs(source_number=790))
self.db.upsert_dependency_edge(
**_edge_kwargs(
source_kind=PR,
source_number=791,
edge_type=dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
)
)
self.db.upsert_dependency_edge(
**_edge_kwargs(source_number=792, target_number=999)
)
waiters = self.db.list_dependency_edges(target_kind=ISSUE, target_number=628)
self.assertEqual(
sorted(edge["source_number"] for edge in waiters), [784, 790, 791]
)
def test_forward_lookup_and_state_filter(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs(target_number=628))
self.db.upsert_dependency_edge(**_edge_kwargs(target_number=603, state="met"))
blockers = self.db.list_dependency_edges(source_number=784, state="unmet")
self.assertEqual([edge["target_number"] for edge in blockers], [628])
def test_scope_isolation(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs())
self.db.upsert_dependency_edge(**_edge_kwargs(repo="Other-Repo"))
self.assertEqual(
len(self.db.list_dependency_edges(remote="prgs", repo="Gitea-Tools")), 1
)
self.assertEqual(
len(self.db.list_dependency_edges(remote="prgs", repo="Other-Repo")), 1
)
self.assertEqual(len(self.db.list_dependency_edges(remote="dadeschools")), 0)
def test_observation_records_transition_with_prior_state(self) -> None:
edge = self.db.upsert_dependency_edge(**_edge_kwargs())
updated = self.db.record_dependency_edge_observation(
edge["edge_id"],
state="met",
evidence={"observed_state": "closed"},
detail="target closed by merge",
)
self.assertEqual(updated["prior_state"], "unmet")
self.assertEqual(updated["state"], "met")
self.assertTrue(updated["state_changed"])
events = self._events()
self.assertEqual(len(events), 1)
self.assertIn("unmet -> met", events[0][1])
self.assertIn("target closed by merge", events[0][1])
def test_observation_on_unknown_edge_fails_closed(self) -> None:
with self.assertRaises(ControlPlaneError):
self.db.record_dependency_edge_observation("no-such-edge", state="met")
def test_evidence_never_persists_a_credential_or_endpoint(self) -> None:
self.db.upsert_dependency_edge(
**_edge_kwargs(
evidence={
"token": "super-secret",
"source": "GET https://gitea.example.invalid/api/v1/issues/628",
}
)
)
conn = sqlite3.connect(self.db.db_path)
try:
raw = conn.execute("SELECT evidence FROM dependency_edges").fetchone()[0]
finally:
conn.close()
self.assertNotIn("super-secret", raw)
self.assertNotIn("https://", raw)
stored = json.loads(raw)
self.assertEqual(stored["token"], dependency_graph.REDACTED)
self.assertEqual(
self.db.list_dependency_edges()[0]["evidence"]["token"],
dependency_graph.REDACTED,
)
class ResolutionIngestionTest(unittest.TestCase):
"""AC11, AC12: allocation-run ingestion and write-failure tolerance."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _resolution(self):
# Same call the allocator makes: parse the body, resolve live state.
body = "* Parent: #628 · Depends: #601, #603, #999 · Related: #613"
refs = allocator_dependencies.parse_dependency_refs(body)
live = {601: "closed", 603: "open", 999: None}
return allocator_dependencies.resolve_dependency_state(
refs, lambda n: live[n], subject="issue#784"
)
def test_one_edge_per_reference_with_matching_state(self) -> None:
resolution = self._resolution()
reasons = dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
source_number=784,
resolution=resolution,
observed_by="prgs-author-1234-abcd",
)
self.assertEqual(reasons, [])
edges = {
edge["target_number"]: edge
for edge in self.db.list_dependency_edges(source_number=784)
}
self.assertEqual(sorted(edges), [601, 603, 999])
self.assertEqual(edges[601]["state"], "met")
self.assertEqual(edges[603]["state"], "unmet")
self.assertEqual(edges[999]["state"], "unavailable")
self.assertEqual(edges[999]["evidence"]["observed_state"], "unavailable")
self.assertEqual(
edges[603]["evidence"]["observed_by_session"], "prgs-author-1234-abcd"
)
self.assertEqual(edges[601]["edge_type"], EDGE_BLOCKED)
def test_unavailable_evidence_is_never_recorded_as_met(self) -> None:
resolution = self._resolution()
dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=resolution,
)
met = self.db.list_dependency_edges(state="met")
self.assertEqual([edge["target_number"] for edge in met], [601])
def test_store_write_failure_is_reported_not_raised(self) -> None:
class BrokenStore:
def upsert_dependency_edge(self, **_kwargs):
raise RuntimeError("disk is on fire")
reasons = dependency_graph.record_issue_dependency_edges(
BrokenStore(),
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=self._resolution(),
)
self.assertEqual(len(reasons), 3)
self.assertTrue(all("disk is on fire" in reason for reason in reasons))
def test_no_declared_dependencies_writes_nothing(self) -> None:
resolution = allocator_dependencies.resolve_dependency_state(
(), lambda n: "closed", subject="issue#784"
)
reasons = dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=resolution,
)
self.assertEqual(reasons, [])
self.assertEqual(self.db.list_dependency_edges(), [])
class AllocationRunIngestionTest(unittest.TestCase):
"""AC11, AC12, AC14: the live allocator path writes edges without changing
what it selects."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
@staticmethod
def _issue(number: int, *, body: str = "") -> dict:
return {
"number": number,
"title": f"issue {number}",
"body": body,
"labels": [{"name": "status:ready"}],
"state": "open",
}
def _fake_gitea(self, issues, *, closed=()):
closed_set = set(closed)
def api_get_all(url, _auth, **_kw):
if "/pulls" in url:
return []
return list(issues)
def api_request(_method, url, _auth, **_kw):
number = int(url.rsplit("/", 1)[-1])
state = "closed" if number in closed_set else "open"
return {"number": number, "state": state}
return api_get_all, api_request
def _allocate(self, issues, *, closed=(), db, **kwargs):
api_get_all, api_request = self._fake_gitea(issues, closed=closed)
with patch(
"gitea_mcp_server._profile_operation_gate", return_value=None
), patch(
"gitea_mcp_server._resolve", return_value=("h", "O", "R")
), patch(
"gitea_mcp_server._auth", return_value="token REDACTED"
), patch(
"gitea_mcp_server.get_profile",
return_value={"profile_name": "prgs-author", "role": "author"},
), patch(
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(db, [])
), patch(
"gitea_mcp_server.api_get_all", side_effect=api_get_all
), patch(
"gitea_mcp_server.api_request", side_effect=api_request
), patch(
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
):
return srv.gitea_allocate_next_work(
remote="prgs", org="O", repo="R", role="author", **kwargs
)
def test_live_run_persists_one_edge_per_declared_reference(self) -> None:
issues = [
self._issue(600, body="* Parent: #900 · Depends: #601, #500"),
self._issue(601),
self._issue(602),
]
result = self._allocate(issues, closed={500}, db=self.db)
self.assertTrue(result["success"])
edges = self.db.list_dependency_edges(remote="prgs", org="O", repo="R")
by_target = {edge["target_number"]: edge for edge in edges}
self.assertEqual(sorted(by_target), [500, 601])
self.assertEqual(by_target[601]["state"], "unmet")
self.assertEqual(by_target[500]["state"], "met")
self.assertEqual(by_target[601]["source_number"], 600)
self.assertEqual(
by_target[601]["edge_type"], dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
)
self.assertTrue(by_target[601]["evidence"]["observed_by_session"])
def test_selection_is_unchanged_by_the_store(self) -> None:
issues = [
self._issue(600, body="* Depends: #601"),
self._issue(601),
self._issue(602),
]
class DeadStore:
"""Stands in for a control-plane DB whose edge writes all fail."""
def __init__(self, real):
self._real = real
def __getattr__(self, name):
return getattr(self._real, name)
def upsert_dependency_edge(self, **_kwargs):
raise RuntimeError("edge store unavailable")
healthy = self._allocate(issues, db=self.db)
broken = self._allocate(issues, db=DeadStore(self.db))
self.assertEqual(
healthy["selected"]["number"], broken["selected"]["number"]
)
self.assertEqual(
{s["number"] for s in healthy["skipped"]},
{s["number"] for s in broken["skipped"]},
)
self.assertEqual(healthy["candidate_count"], broken["candidate_count"])
self.assertTrue(broken["success"])
warnings = broken.get("inventory_warnings") or []
self.assertTrue(
any("edge store unavailable" in str(w) for w in warnings),
f"write failure must surface in reasons, got {warnings}",
)
def test_repeated_runs_do_not_duplicate_edges(self) -> None:
issues = [self._issue(600, body="* Depends: #601"), self._issue(601)]
self._allocate(issues, db=self.db)
self._allocate(issues, db=self.db)
self.assertEqual(len(self.db.list_dependency_edges()), 1)
class ListDependencyEdgesToolTest(unittest.TestCase):
"""AC13: the read-only tool is gated and never mutates."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
self.db.upsert_dependency_edge(**_edge_kwargs(org="O", repo="R"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _call(self, *, read_block=None, **kwargs):
with patch(
"gitea_mcp_server._profile_operation_gate", return_value=read_block
), patch(
"gitea_mcp_server._resolve", return_value=("h", "O", "R")
), patch(
"gitea_mcp_server._permission_block_report", return_value={"blocked": True}
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
):
return srv.gitea_list_dependency_edges(remote="prgs", **kwargs)
def test_returns_stored_edges(self) -> None:
result = self._call()
self.assertTrue(result["success"])
self.assertTrue(result["read_only"])
self.assertEqual(result["count"], 1)
self.assertEqual(result["edges"][0]["target_number"], 628)
self.assertEqual(len(result["edge_types"]), 7)
def test_reverse_lookup_filter(self) -> None:
self.assertEqual(self._call(target_number=628)["count"], 1)
self.assertEqual(self._call(target_number=999)["count"], 0)
def test_without_read_permission_it_fails_closed(self) -> None:
result = self._call(read_block=["gitea.read not allowed"])
self.assertFalse(result["success"])
self.assertEqual(result["edges"], [])
self.assertIn("permission_report", result)
def test_invalid_filter_fails_closed(self) -> None:
result = self._call(edge_type="not_a_real_type")
self.assertFalse(result["success"])
self.assertEqual(result["edges"], [])
self.assertTrue(any("fail closed" in r for r in result["reasons"]))
def test_tool_is_documented_in_the_inventory(self) -> None:
"""The #781 drift guard requires a registered tool to be documented."""
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
doc = os.path.join(repo_root, mcp_tool_inventory.INVENTORY_DOC_PATH)
with open(doc, "r", encoding="utf-8") as handle:
documented = mcp_tool_inventory.parse_documented_inventory(handle.read())
self.assertIn("gitea_list_dependency_edges", documented)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+198
View File
@@ -78,6 +78,95 @@ class TestBlockReasonsAndReport(unittest.TestCase):
self.assertTrue(report["recovery"])
class TestLiveRemoteParity(unittest.TestCase):
"""#610: parity must account for the live remote master, not just local.
The daemon can be stale relative to the live remote target while the local
checkout HEAD still matches the daemon's startup commit, so local parity
reports green even though a mutation would run against outdated code.
"""
SHA_C = "c" * 40
def test_distinguishes_three_shas(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertEqual(res["daemon_start_head"], SHA_A)
self.assertEqual(res["local_head"], SHA_A)
self.assertEqual(res["live_remote_head"], SHA_B)
def test_mutation_safe_only_when_all_three_match(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
self.assertTrue(res["mutation_safe"])
self.assertTrue(res["live_known"])
self.assertFalse(res["live_stale"])
def test_live_stale_when_remote_advanced_past_daemon(self):
# Local checkout still matches the daemon start (local parity green),
# but the live remote master has advanced -> daemon is live-stale.
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertTrue(res["in_parity"]) # local parity still green
self.assertTrue(res["live_stale"])
self.assertFalse(res["mutation_safe"])
self.assertTrue(any("live" in r.lower() for r in res["reasons"]))
def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
# Non-goal: unfetchable live remote must not be treated as stale for
# read-only, but a mutation-safe claim fails closed.
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=None)
self.assertFalse(res["live_known"])
self.assertFalse(res["mutation_safe"])
self.assertFalse(res["live_stale"])
self.assertTrue(res["in_parity"])
def test_default_live_remote_preserves_legacy_shape(self):
# Callers that do not supply a live head keep the pre-#610 behavior:
# in-parity, not live-stale, no live-derived block.
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertFalse(res["live_stale"])
self.assertEqual(mp.parity_block_reasons(res), [])
class TestLiveStaleBlockAndReport(unittest.TestCase):
"""#610: live-staleness must block mutations and surface a typed blocker."""
def test_live_stale_produces_block_reasons(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertTrue(mp.parity_block_reasons(res))
def test_disable_env_suppresses_live_stale_block(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
self.assertEqual(mp.parity_block_reasons(res), [])
def test_resolver_disagreement_returns_typed_blocker(self):
# Parity says local-green, resolver says restart required -> disagreement
# is a typed, fail-closed blocker naming the resolver as authoritative.
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
self.assertIsNotNone(blocker)
self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
self.assertTrue(blocker["restart_required"])
self.assertTrue(blocker["resolver_authoritative"])
def test_no_disagreement_when_resolver_agrees(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertIsNone(
mp.parity_resolver_disagreement(res, resolver_restart_required=False))
def test_live_stale_report_names_live_remote(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
report = mp.parity_report(res)
self.assertEqual(report["live_remote_head"], SHA_B)
self.assertTrue(report["restart_required"])
class TestReadGitHead(unittest.TestCase):
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
@@ -95,6 +184,78 @@ class TestReadGitHead(unittest.TestCase):
self.assertIsNone(mp.read_git_head(""))
class TestReadRemoteMasterHead(unittest.TestCase):
"""#610: live remote master head reader (env-overridable, fails to None)."""
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)
def test_blank_override_is_none(self):
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: " "}):
self.assertIsNone(mp.read_remote_master_head("/nonexistent"))
def test_unfetchable_remote_is_none(self):
# No override; a bogus root/remote must fail closed to None, never raise.
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
with patch.dict(os.environ, env, clear=True):
self.assertIsNone(
mp.read_remote_master_head("/nonexistent", remote="nope"))
class TestRemoteHeadCache(unittest.TestCase):
"""#610: live remote reads are cached with a TTL to stay off the network.
The parity gate runs on every mutation and every runtime-context read, so an
unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
"""
def setUp(self):
mp._clear_remote_head_cache()
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
self._env = patch.dict(os.environ, env, clear=True)
self._env.start()
self.addCleanup(self._env.stop)
self.addCleanup(mp._clear_remote_head_cache)
def _fake_run(self, sha):
class _R:
returncode = 0
stdout = f"{sha}\trefs/heads/master\n"
calls = {"n": 0}
def run(*args, **kwargs):
calls["n"] += 1
return _R()
return run, calls
def test_second_call_within_ttl_uses_cache(self):
run, calls = self._fake_run(SHA_B)
with patch.object(mp.subprocess, "run", run):
a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
self.assertEqual(a, SHA_B)
self.assertEqual(b, SHA_B)
self.assertEqual(calls["n"], 1)
def test_zero_ttl_bypasses_cache(self):
run, calls = self._fake_run(SHA_B)
with patch.object(mp.subprocess, "run", run):
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
self.assertEqual(calls["n"], 2)
def test_env_override_never_touches_subprocess(self):
run, calls = self._fake_run(SHA_B)
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
with patch.object(mp.subprocess, "run", run):
self.assertEqual(
mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
self.assertEqual(calls["n"], 0)
class TestServerWiring(unittest.TestCase):
"""Integration with the gate choke point in the server namespace."""
@@ -105,6 +266,13 @@ class TestServerWiring(unittest.TestCase):
self._saved = self.srv._STARTUP_PARITY
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
"startup_head": SHA_A}
# Keep the live-remote read hermetic (no real ls-remote network call):
# default the live master to the daemon start so parity is fully green
# unless a test overrides the live head explicitly (#610).
self._live_patch = patch.dict(
os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
self._live_patch.start()
self.addCleanup(self._live_patch.stop)
def tearDown(self):
self.srv._STARTUP_PARITY = self._saved
@@ -147,6 +315,36 @@ class TestServerWiring(unittest.TestCase):
self.assertTrue(out["in_parity"])
self.assertNotIn("report", out)
# --- #610: live-remote wiring -------------------------------------------
def test_live_stale_blocks_mutation_though_local_green(self):
# Local checkout matches the daemon start (local parity green) but the
# live remote master has advanced -> mutations must fail closed.
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
self.assertTrue(
self.srv._master_parity_block("gitea.pr.create"))
def test_assess_tool_exposes_three_distinct_shas(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertEqual(out["daemon_start_head"], SHA_A)
self.assertEqual(out["local_head"], SHA_A)
self.assertEqual(out["live_remote_head"], SHA_B)
self.assertTrue(out["live_stale"])
self.assertFalse(out["mutation_safe"])
self.assertIn("report", out)
def test_assess_tool_mutation_safe_when_all_three_match(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertTrue(out["mutation_safe"])
self.assertFalse(out["live_stale"])
self.assertNotIn("report", out)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -10,6 +10,7 @@ DOCS = REPO_ROOT / "docs" / "mcp-menu.md"
REQUIRED_MENU_LABELS = (
"Project status / root checkout health",
"Workflow dashboard (queue, leases, next safe action)",
"Author workflow prompts",
"Reviewer workflow prompts",
"Merger workflow prompts",
@@ -105,6 +106,23 @@ class TestMcpMenuScript(unittest.TestCase):
self.assertIn("./mcp-menu.sh", docs_text)
self.assertIn("placeholder", docs_text.lower())
def test_workflow_dashboard_menu_entry_is_read_only(self):
# #605: dashboard entry documents gitea_workflow_dashboard and never
# mutates Gitea / assigns work from the shell menu.
label = "Workflow dashboard (queue, leases, next safe action)"
self.assertIn(label, self.content)
dash_fn = self._extract_function("show_workflow_dashboard_help")
self.assertIn("gitea_workflow_dashboard", dash_fn)
self.assertIn("gitea_allocate_next_work", dash_fn)
self.assertIn("Read-only", dash_fn)
self.assertIn("never presented as safe", dash_fn.lower())
for bad in ("gitea_merge_pr", "gitea_submit_pr_review", "git push"):
with self.subTest(bad=bad):
self.assertNotIn(bad, dash_fn)
docs_text = DOCS.read_text(encoding="utf-8")
self.assertIn("gitea_workflow_dashboard", docs_text)
self.assertIn("Workflow dashboard", docs_text)
def test_reviewer_skip_stale_request_changes_prompt_discoverable(self):
# #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt
# must be reachable from the reviewer menu and documented.
+388
View File
@@ -0,0 +1,388 @@
"""Tests for the #617 mutation-budget classifier.
Covers every acceptance criterion on issue #617:
* AC1 the classifier distinguishes local validator rejection, capability-gate
rejection, transport failure before API, and successful server-side mutation.
* AC2 pre-API validator failures do not consume server-side mutation budget.
* AC3 failed attempts are still logged in the local attempt ledger.
* AC4 the final report separately shows local failed attempts, blocked API
attempts, and successful server-side mutations.
* AC5 the six named scenarios, including the #615 reproduction where two
local validator rejections precede one successful comment.
"""
from __future__ import annotations
import unittest
from mutation_budget_classifier import (
AMBIGUOUS_REQUIRES_READBACK,
CAPABILITY_GATE_REJECTION,
LOCAL_VALIDATOR_REJECTION,
SERVER_SIDE_MUTATION,
TRANSPORT_FAILURE_BEFORE_API,
assess_final_report_mutation_accounting,
classify_mutation_attempt,
record_attempt,
summarize_attempt_ledger,
)
# The two pre-API rejections observed on the #615 comment flow.
MISSING_LEDGER_BLOCK = {
"success": False,
"performed": False,
"api_called": False,
"reasons": ["missing [THREAD STATE LEDGER] block"],
}
MISSING_CANONICAL_STATE = {
"success": False,
"performed": False,
"api_called": False,
"reasons": ["missing ## Canonical Issue State block"],
}
# The corrected comment that actually landed as #615 comment 9137.
SUCCESSFUL_COMMENT = {
"success": True,
"performed": True,
"api_called": True,
"comment_id": 9137,
"issue_number": 615,
}
class TestAC1Classification(unittest.TestCase):
"""AC1: the four outcome classes are distinguished."""
def test_local_validator_rejection_is_its_own_class(self):
result = classify_mutation_attempt(MISSING_LEDGER_BLOCK)
self.assertEqual(result["classification"], LOCAL_VALIDATOR_REJECTION)
self.assertTrue(result["pre_api"])
def test_capability_gate_rejection_is_its_own_class(self):
result = classify_mutation_attempt(
{
"success": False,
"api_called": False,
"permission_report": {"missing_permission": "gitea.issue.comment"},
}
)
self.assertEqual(result["classification"], CAPABILITY_GATE_REJECTION)
self.assertTrue(result["pre_api"])
def test_transport_failure_before_api_is_its_own_class(self):
result = classify_mutation_attempt(
{"success": False, "api_called": False, "transport_error": "EOF"}
)
self.assertEqual(result["classification"], TRANSPORT_FAILURE_BEFORE_API)
self.assertTrue(result["pre_api"])
def test_successful_server_mutation_is_its_own_class(self):
result = classify_mutation_attempt(SUCCESSFUL_COMMENT)
self.assertEqual(result["classification"], SERVER_SIDE_MUTATION)
self.assertFalse(result["pre_api"])
def test_each_class_is_distinct(self):
classes = {
classify_mutation_attempt(payload)["classification"]
for payload in (
MISSING_LEDGER_BLOCK,
{"success": False, "api_called": False, "capability_blocked": True},
{"success": False, "api_called": False, "transport_failed": True},
SUCCESSFUL_COMMENT,
)
}
self.assertEqual(len(classes), 4)
class TestAC2BudgetAccounting(unittest.TestCase):
"""AC2: pre-API failures never consume server-side mutation budget."""
def test_missing_thread_state_ledger_not_counted_as_mutation(self):
result = classify_mutation_attempt(MISSING_LEDGER_BLOCK)
self.assertFalse(result["budget_consumed"])
self.assertIs(result["api_called"], False)
def test_missing_canonical_issue_state_not_counted_as_mutation(self):
result = classify_mutation_attempt(MISSING_CANONICAL_STATE)
self.assertFalse(result["budget_consumed"])
self.assertIs(result["api_called"], False)
def test_transport_failure_before_api_not_counted_as_mutation(self):
result = classify_mutation_attempt(
{
"success": False,
"api_called": False,
"transport_error": "connection reset",
}
)
self.assertFalse(result["budget_consumed"])
def test_capability_gate_block_not_counted_as_mutation(self):
result = classify_mutation_attempt(
{
"success": False,
"api_called": False,
"permission_report": {"missing_permission": "gitea.pr.merge"},
}
)
self.assertFalse(result["budget_consumed"])
def test_successful_comment_with_comment_id_counts_as_one_mutation(self):
result = classify_mutation_attempt(SUCCESSFUL_COMMENT)
self.assertTrue(result["budget_consumed"])
self.assertEqual(result["proof_fields"], ["comment_id"])
class TestAC2FailsClosed(unittest.TestCase):
"""AC2 must not become a loophole: ambiguity still fails closed."""
def test_api_reached_without_proof_is_ambiguous_and_consumes_budget(self):
result = classify_mutation_attempt({"success": True, "api_called": True})
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
self.assertTrue(result["budget_consumed"])
self.assertTrue(result["requires_readback"])
def test_missing_api_called_signal_fails_closed(self):
result = classify_mutation_attempt({"success": False})
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
self.assertTrue(result["budget_consumed"])
self.assertIsNone(result["api_called"])
def test_empty_and_none_results_fail_closed(self):
for payload in ({}, None):
result = classify_mutation_attempt(payload)
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
self.assertTrue(result["budget_consumed"])
def test_success_with_proof_counts_even_when_api_called_absent(self):
result = classify_mutation_attempt({"success": True, "comment_id": 13320})
self.assertEqual(result["classification"], SERVER_SIDE_MUTATION)
self.assertTrue(result["budget_consumed"])
def test_blank_proof_field_is_not_proof(self):
result = classify_mutation_attempt(
{"success": True, "api_called": True, "merge_commit_sha": " "}
)
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
class TestAC3AttemptLedger(unittest.TestCase):
"""AC3: failed attempts are still logged locally."""
def test_failed_attempts_are_recorded(self):
ledger: list[dict] = []
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="create_issue_comment")
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="create_issue_comment")
self.assertEqual(len(ledger), 2)
self.assertTrue(
all(e["classification"] == LOCAL_VALIDATOR_REJECTION for e in ledger)
)
def test_recorded_entry_carries_operation_and_timestamp(self):
ledger: list[dict] = []
entry = record_attempt(
ledger,
SUCCESSFUL_COMMENT,
operation="create_issue_comment",
timestamp="2026-07-20T18:15:04+00:00",
)
self.assertEqual(entry["operation"], "create_issue_comment")
self.assertEqual(entry["timestamp"], "2026-07-20T18:15:04+00:00")
def test_timestamp_is_generated_when_omitted(self):
ledger: list[dict] = []
entry = record_attempt(ledger, SUCCESSFUL_COMMENT)
self.assertTrue(entry["timestamp"])
class TestAC5CorrectedCommentAllowed(unittest.TestCase):
"""AC5: the #615 reproduction — two local rejections then one success."""
def _replay_615_flow(self) -> list[dict]:
ledger: list[dict] = []
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="create_issue_comment")
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="create_issue_comment")
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="create_issue_comment")
return ledger
def test_corrected_comment_after_two_rejections_is_allowed(self):
summary = summarize_attempt_ledger(self._replay_615_flow())
# The regression: budget must show ONE mutation, not three attempts.
self.assertEqual(summary["successful_server_mutations"], 1)
self.assertEqual(summary["budget_consumed"], 1)
def test_all_three_attempts_remain_visible(self):
summary = summarize_attempt_ledger(self._replay_615_flow())
self.assertEqual(summary["total_attempts"], 3)
self.assertEqual(summary["local_failed_attempts"], 2)
def test_no_readback_required_for_clean_flow(self):
summary = summarize_attempt_ledger(self._replay_615_flow())
self.assertFalse(summary["requires_readback"])
class TestAC4FinalReportAccounting(unittest.TestCase):
"""AC4: the report must show the three categories, and match the ledger."""
def _mixed_ledger(self) -> list[dict]:
ledger: list[dict] = []
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="comment")
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="comment")
record_attempt(
ledger,
{"success": False, "api_called": False, "transport_error": "EOF"},
operation="comment",
)
record_attempt(
ledger,
{"success": False, "api_called": False, "capability_blocked": True},
operation="merge",
)
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="comment")
return ledger
def test_summary_separates_the_three_categories(self):
summary = summarize_attempt_ledger(self._mixed_ledger())
self.assertEqual(summary["local_failed_attempts"], 2)
self.assertEqual(summary["blocked_api_attempts"], 2)
self.assertEqual(summary["successful_server_mutations"], 1)
def test_matching_report_is_valid(self):
result = assess_final_report_mutation_accounting(
{
"local_failed_attempts": 2,
"blocked_api_attempts": 2,
"successful_server_mutations": 1,
},
self._mixed_ledger(),
)
self.assertTrue(result["valid"], result["reasons"])
def test_omitted_category_fails_closed(self):
result = assess_final_report_mutation_accounting(
{"local_failed_attempts": 2, "blocked_api_attempts": 2},
self._mixed_ledger(),
)
self.assertFalse(result["valid"])
self.assertTrue(
any("successful_server_mutations" in r for r in result["reasons"])
)
def test_inflated_mutation_count_fails_closed(self):
# The #617 bug shape: claiming three mutations when only one landed.
result = assess_final_report_mutation_accounting(
{
"local_failed_attempts": 2,
"blocked_api_attempts": 2,
"successful_server_mutations": 3,
},
self._mixed_ledger(),
)
self.assertFalse(result["valid"])
self.assertTrue(
any("successful_server_mutations=3" in r for r in result["reasons"])
)
def test_ambiguous_attempt_requires_readback_proof(self):
ledger: list[dict] = []
record_attempt(ledger, {"success": True, "api_called": True}, operation="comment")
report = {
"local_failed_attempts": 0,
"blocked_api_attempts": 0,
"successful_server_mutations": 0,
}
blocked = assess_final_report_mutation_accounting(report, ledger)
self.assertFalse(blocked["valid"])
self.assertTrue(any("readback_verified" in r for r in blocked["reasons"]))
allowed = assess_final_report_mutation_accounting(
{**report, "readback_verified": True}, ledger
)
self.assertTrue(allowed["valid"], allowed["reasons"])
def test_ledger_summary_is_returned_without_raw_entries(self):
result = assess_final_report_mutation_accounting({}, self._mixed_ledger())
self.assertNotIn("entries", result["ledger_summary"])
self.assertEqual(result["ledger_summary"]["total_attempts"], 5)
class TestEmptyLedger(unittest.TestCase):
def test_empty_ledger_summarizes_to_zero(self):
summary = summarize_attempt_ledger([])
self.assertEqual(summary["total_attempts"], 0)
self.assertEqual(summary["successful_server_mutations"], 0)
self.assertFalse(summary["requires_readback"])
def test_none_ledger_is_tolerated(self):
self.assertEqual(summarize_attempt_ledger(None)["total_attempts"], 0)
if __name__ == "__main__":
unittest.main()
class TestValidatorIntegration(unittest.TestCase):
"""The classifier is wired into the shared final-report validator (AC4)."""
def _ledger_two_rejections_one_success(self) -> list[dict]:
ledger: list[dict] = []
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="comment")
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="comment")
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="comment")
return ledger
def test_rule_is_noop_without_a_ledger(self):
from final_report_validator import assess_final_report_validator
result = assess_final_report_validator("some report", "review_pr")
self.assertFalse(
any(
f["rule_id"] == "shared.mutation_budget_accounting"
for f in result["findings"]
)
)
def test_report_matching_ledger_produces_no_finding(self):
from final_report_validator import assess_final_report_validator
report = (
"Local failed attempts: 2\n"
"Blocked API attempts: 0\n"
"Successful server-side mutations: 1\n"
)
result = assess_final_report_validator(
report,
"review_pr",
mutation_attempt_ledger=self._ledger_two_rejections_one_success(),
)
self.assertFalse(
any(
f["rule_id"] == "shared.mutation_budget_accounting"
for f in result["findings"]
)
)
def test_counting_rejections_as_mutations_is_blocked(self):
from final_report_validator import assess_final_report_validator
# The #617 bug: three attempts reported as three server-side mutations.
report = (
"Local failed attempts: 0\n"
"Blocked API attempts: 0\n"
"Successful server-side mutations: 3\n"
)
result = assess_final_report_validator(
report,
"review_pr",
mutation_attempt_ledger=self._ledger_two_rejections_one_success(),
)
findings = [
f
for f in result["findings"]
if f["rule_id"] == "shared.mutation_budget_accounting"
]
self.assertTrue(findings)
self.assertTrue(all(f["severity"] == "block" for f in findings))
+14 -3
View File
@@ -224,9 +224,20 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master"},
):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity("prgs")
self.assertIn("stable control checkout", str(ctx.exception))
with mock.patch(
"gitea_mcp_server._session_author_lock_worktree",
return_value=None,
):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity("prgs")
blob = str(ctx.exception)
self.assertTrue(
"stable control checkout" in blob
or "control checkout" in blob
or "#618" in blob
or "author worktree" in blob.lower(),
msg=blob,
)
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
+44 -3
View File
@@ -19,6 +19,8 @@ from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server # noqa: E402
import post_merge_moot_lease_gate as moot_gate # noqa: E402
import reviewer_pr_lease as leases # noqa: E402
from mcp_server import ( # noqa: E402
gitea_acquire_reviewer_pr_lease,
@@ -30,6 +32,13 @@ MERGER_ENV = {
"GITEA_PROFILE_NAME": "prgs-merger",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
}
# #745: applying the terminal marker is reconciler-owned.
RECONCILER_ENV = {
"GITEA_PROFILE_NAME": "prgs-reconciler",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment,gitea.pr.close",
}
CLEANUP_TASK = moot_gate.CLEANUP_TASK
REPO_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
PR = 487
ISSUE = 485
SESSION = "97274-676d20a825c4"
@@ -204,6 +213,8 @@ class TestAcquireToolRefusesMergedPR(unittest.TestCase):
class TestCleanupTool(unittest.TestCase):
def setUp(self):
leases.clear_session_lease()
moot_gate._reset_for_testing()
self.addCleanup(moot_gate._reset_for_testing)
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.api_request")
@@ -223,23 +234,53 @@ class TestCleanupTool(unittest.TestCase):
self.assertEqual(calls["post"], [])
@patch("mcp_server.verify_preflight_purity", return_value=None)
@patch("mcp_server._repository_binding_block", return_value=None)
@patch("mcp_server._bound_repository_slug", return_value=REPO_SLUG)
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.api_request")
def test_apply_posts_released_marker_on_merged_pr(
self, mock_api, _auth, _purity):
self, mock_api, _auth, _slug, _binding, _purity):
"""#745: apply is reconciler-only and needs a matching dry run first."""
side, calls = _api_side_effect(
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
mock_api.side_effect = side
with patch.dict(os.environ, MERGER_ENV, clear=True):
with patch.object(mcp_server, "_preflight_resolved_task",
CLEANUP_TASK), \
patch.dict(os.environ, RECONCILER_ENV, clear=True):
gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=False, remote="prgs")
result = gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=True, remote="prgs")
self.assertTrue(result["success"])
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["cleanup_performed"])
self.assertEqual(result["released_comment_id"], 9999)
self.assertEqual(len(calls["post"]), 1)
self.assertIn("phase: released", calls["post"][0]["payload"]["body"])
self.assertIn("post-merge-moot", calls["post"][0]["payload"]["body"])
@patch("mcp_server.verify_preflight_purity", return_value=None)
@patch("mcp_server._repository_binding_block", return_value=None)
@patch("mcp_server._bound_repository_slug", return_value=REPO_SLUG)
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.api_request")
def test_merger_can_no_longer_apply(
self, mock_api, _auth, _slug, _binding, _purity):
"""#745: holding gitea.pr.comment is no longer sufficient to apply."""
side, calls = _api_side_effect(
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
mock_api.side_effect = side
with patch.object(mcp_server, "_preflight_resolved_task",
CLEANUP_TASK), \
patch.dict(os.environ, MERGER_ENV, clear=True):
gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=False, remote="prgs")
result = gitea_cleanup_post_merge_moot_lease(
pr_number=PR, apply=True, remote="prgs")
self.assertFalse(result["success"])
self.assertFalse(result["cleanup_performed"])
self.assertEqual(result["blocker_kind"], "wrong_role")
self.assertEqual(calls["post"], [])
@patch("mcp_server.verify_preflight_purity", return_value=None)
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.api_request")
+21 -13
View File
@@ -38,11 +38,17 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
self._orig_resolved_task = srv._preflight_resolved_task
self._orig_resolved_role = srv._preflight_resolved_role
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
self._env_patch.start()
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
# Preflight task/role are module-level; restore so test order cannot
# leak a resolved task into sibling cases.
srv._preflight_resolved_task = self._orig_resolved_task
srv._preflight_resolved_role = self._orig_resolved_role
self._env_patch.stop()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@@ -81,26 +87,28 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master"},
)
def test_author_create_issue_still_blocked_on_control_checkout(
def test_author_non_create_issue_still_blocked_on_control_checkout(
self, _git, _get_all, _role, _ns, _prof, _auth
):
"""Author mutations other than create_issue keep the branches-only rule.
#749/#750 sanctioned ``create_issue`` from a clean control checkout, and
#757 made the #604 anti-stomp guard honour that same decision — so
``create_issue`` is no longer a valid probe for this boundary. This case
previously asserted create_issue stayed blocked, which only held because
the bootstrap-blind #604 guard was overriding #750; that is precisely
the defect #757 fixed. ``lock_issue`` is issue-backed and post-ownership,
so it still requires a ``branches/`` worktree.
"""
srv._preflight_resolved_role = "author"
srv._preflight_resolved_task = "lock_issue"
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
try:
res = srv.gitea_create_issue(title="Test", body="body")
srv.verify_preflight_purity(remote="prgs", task="lock_issue")
except RuntimeError as exc:
self.assertIn("stable control checkout", str(exc))
self.assertIn("control checkout", str(exc).lower())
else:
# #683 typed blocker at mutation entrypoint
self.assertFalse(res.get("success"))
blob = " ".join(res.get("reasons") or []) + str(
res.get("blocker_kind") or ""
)
self.assertTrue(
"stable control checkout" in blob
or "missing_issue_worktree" in blob
or "control checkout" in blob.lower()
)
self.fail("lock_issue must stay blocked on the control checkout")
if __name__ == "__main__":
+26
View File
@@ -242,6 +242,32 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertTrue(res.get("stop_required"))
self.assertIs(res.get("mutation_performed"), False)
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_denied_role_exclusive_resolution_does_not_stamp_role(
self, _auth, _api
):
with patch.dict(os.environ, self._env("author-profile")):
with patch.object(
mcp_server,
"record_preflight_check",
wraps=mcp_server.record_preflight_check,
) as record:
result = mcp_server.gitea_resolve_task_capability(
task="review_pr", remote="prgs"
)
self.assertFalse(result["allowed_in_current_session"], result)
self.assertFalse(
any(
len(call.args) > 1 and call.args[1] == "reviewer"
for call in record.call_args_list
),
"denied reviewer resolution must never record a reviewer stamp",
)
self.assertIsNone(mcp_server._preflight_resolved_role)
self.assertIsNone(mcp_server._preflight_resolved_task)
# Additional regression tests per #145 for permission boundaries and structured guidance
def test_issue_comment_does_not_imply_close(self):
# Author profile has issue.comment but not issue.close
+13 -6
View File
@@ -79,22 +79,26 @@ class TestReviewerLeaseAcquire(unittest.TestCase):
class TestReviewerLeaseFreshness(unittest.TestCase):
def test_stale_warning_after_30_minutes(self):
def test_stale_warning_at_half_the_sliding_window(self):
# #747 warns at half the 10-minute window, while the owner can still
# heartbeat and keep the lease.
lease = leases.parse_lease_comment(
_lease_comment(382, "session-a", minutes_ago=35)["body"]
_lease_comment(382, "session-a", minutes_ago=6)["body"]
)
self.assertEqual(
leases.classify_lease_freshness(lease),
"stale_warning",
)
def test_reclaimable_after_60_minutes(self):
def test_expired_once_the_sliding_window_lapses(self):
# Pre-#747 a 65-minute-idle lease was "reclaimable" and had to wait out
# a second timer. It is now simply expired and immediately takeable.
lease = leases.parse_lease_comment(
_lease_comment(382, "session-a", minutes_ago=65)["body"]
)
self.assertEqual(
leases.classify_lease_freshness(lease),
"reclaimable",
"expired",
)
@@ -281,7 +285,10 @@ class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
self.assertEqual(result["active_lease"]["comment_id"], 8647)
self.assertFalse(result["mutation_allowed"])
def test_foreign_reclaimable_release_expired(self):
def test_foreign_expired_release_expired(self):
# Pre-#747 this classified as "foreign_reclaimable" after the 60-minute
# activity band. Under the sliding TTL the lease is simply expired, and
# the sanctioned next action is unchanged.
reclaim = _lease_comment(
592, "foreign-old", phase="claimed", minutes_ago=65
)
@@ -293,7 +300,7 @@ class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
current_reviewer_identity="sysadmin",
proposed_worktree="branches/review-pr-592",
)
self.assertEqual(result["classification"], "foreign_reclaimable")
self.assertEqual(result["classification"], "foreign_expired")
self.assertEqual(
result["next_action"], leases.NEXT_ACTION_RELEASE_EXPIRED_LEASE
)
+613
View File
@@ -0,0 +1,613 @@
"""Tests for self-propagating canonical handoffs (#626)."""
from __future__ import annotations
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from final_report_validator import assess_final_report_validator # noqa: E402
from self_propagating_handoff import ( # noqa: E402
HANDOFF_FIELDS,
NEXT_ACTOR_BY_STATE,
WORKFLOW_STATES,
assess_controller_decision,
assess_durable_state_update,
assess_final_report_self_propagating_handoff,
assess_handoff_live_state,
assess_merge_completion_transition,
assess_role_continuation,
assess_self_propagating_handoff,
assess_thread_recoverability,
assess_workflow_failure_escalation,
parse_self_propagating_handoff,
render_self_propagating_handoff,
)
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
AUTHOR_PROMPT = (
"Review PR #900 on Scaled-Tech-Consulting/Gitea-Tools for issue 626 at head "
"aaaa111. Validate the branch, then submit an independent review verdict."
)
REVIEWER_PROMPT = (
"Merge PR #900 on Scaled-Tech-Consulting/Gitea-Tools for issue 626 once the "
"approval at head aaaa111 still applies to the live head."
)
MERGER_PROMPT = (
"Accept or reject the merged work for issue 626 on "
"Scaled-Tech-Consulting/Gitea-Tools; verify acceptance criteria then close."
)
CONTROLLER_PROMPT = (
"Address the controller's requested changes for issue 626 on "
"Scaled-Tech-Consulting/Gitea-Tools, then hand back to an independent reviewer."
)
def build_handoff(**overrides):
"""Render a valid author -> reviewer handoff, with overrides applied."""
values = {
"REPOSITORY": REPO,
"ISSUE": "626",
"PR": "900",
"WORKFLOW_STATE": "needs-review",
"HEAD_SHA": "aaaa111",
"BASE_BRANCH": "master",
"BASE_OR_MERGE_SHA": "bbbb222",
"ACTING_ROLE": "author",
"ACTING_IDENTITY": "jcwalker3 (prgs-author)",
"COMPLETED_ACTIONS": "implemented AC1-AC9; opened PR #900",
"VALIDATION_EVIDENCE": "pytest tests/test_self_propagating_handoff.py: 20 passed",
"MUTATION_LEDGER": "branch pushed; PR #900 opened; comment 13547 posted",
"BLOCKERS": "none",
"NEXT_ACTOR": "reviewer",
"NEXT_ACTION": "independently review PR #900 at head aaaa111",
"PROHIBITED_ACTIONS": "merge, self-approve, force-push",
"NEXT_PROMPT": AUTHOR_PROMPT,
"WORKFLOW_FAILURE_ISSUES": "none",
"LAST_UPDATED": "2026-07-21T03:55:00Z",
}
values.update(overrides)
return render_self_propagating_handoff(**values)
class RenderAndParseTests(unittest.TestCase):
def test_render_emits_every_canonical_field(self):
body = build_handoff()
parsed = parse_self_propagating_handoff(body)
self.assertIsNotNone(parsed)
for name in HANDOFF_FIELDS:
self.assertIn(name, parsed)
def test_render_rejects_unknown_workflow_state(self):
with self.assertRaises(ValueError):
build_handoff(WORKFLOW_STATE="almost-done")
def test_every_state_maps_to_exactly_one_actor(self):
self.assertEqual(set(WORKFLOW_STATES), set(NEXT_ACTOR_BY_STATE))
def test_absent_block_parses_as_none(self):
self.assertIsNone(parse_self_propagating_handoff("no handoff here"))
class AuthorToReviewerTests(unittest.TestCase):
"""Scenario 1: author -> reviewer."""
def test_valid_author_handoff_passes(self):
result = assess_self_propagating_handoff(build_handoff())
self.assertTrue(result["valid"], result["reasons"])
self.assertEqual(result["next_actor"], "reviewer")
self.assertFalse(result["terminal"])
def test_reviewer_may_continue_author_handoff(self):
result = assess_role_continuation(
handoff=build_handoff(), actor_role="reviewer"
)
self.assertTrue(result["allowed"], result["reasons"])
self.assertIn("review", result["allowed_actions"])
def test_author_may_not_continue_its_own_handoff(self):
result = assess_role_continuation(
handoff=build_handoff(), actor_role="author"
)
self.assertTrue(result["block"])
self.assertEqual(result["expected_actor"], "reviewer")
def test_next_actor_must_match_declared_state(self):
result = assess_self_propagating_handoff(
build_handoff(NEXT_ACTOR="merger")
)
self.assertTrue(result["block"])
self.assertTrue(
any("does not match state" in reason for reason in result["reasons"])
)
class ReviewerToMergerTests(unittest.TestCase):
"""Scenario 2: reviewer -> merger."""
def build(self, **overrides):
values = {
"WORKFLOW_STATE": "approved-awaiting-merge",
"ACTING_ROLE": "reviewer",
"ACTING_IDENTITY": "reviewer-bot (prgs-reviewer)",
"COMPLETED_ACTIONS": "review 500 APPROVED at aaaa111",
"NEXT_ACTOR": "merger",
"NEXT_ACTION": "merge PR #900 at approved head aaaa111",
"PROHIBITED_ACTIONS": "re-review, commit, push",
"NEXT_PROMPT": REVIEWER_PROMPT,
}
values.update(overrides)
return build_handoff(**values)
def test_reviewer_handoff_is_valid(self):
result = assess_self_propagating_handoff(self.build())
self.assertTrue(result["valid"], result["reasons"])
self.assertEqual(result["next_actor"], "merger")
def test_merger_may_continue(self):
result = assess_role_continuation(handoff=self.build(), actor_role="merger")
self.assertTrue(result["allowed"], result["reasons"])
self.assertIn("merge", result["allowed_actions"])
class MergerToControllerTests(unittest.TestCase):
"""Scenario 3: merger -> controller."""
def test_merge_success_stops_at_controller_boundary(self):
result = assess_merge_completion_transition(merge_succeeded=True)
self.assertEqual(result["next_state"], "merged-awaiting-controller")
self.assertEqual(result["next_actor"], "controller")
self.assertTrue(result["next_prompt_required"])
def test_configured_auto_accept_may_complete(self):
result = assess_merge_completion_transition(
merge_succeeded=True, controller_auto_accept=True
)
self.assertEqual(result["next_state"], "complete")
self.assertFalse(result["next_prompt_required"])
def test_failed_merge_keeps_the_work_item_with_the_merger(self):
result = assess_merge_completion_transition(merge_succeeded=False)
self.assertEqual(result["next_state"], "approved-awaiting-merge")
def test_merger_handoff_names_the_controller(self):
body = build_handoff(
WORKFLOW_STATE="merged-awaiting-controller",
ACTING_ROLE="merger",
ACTING_IDENTITY="merger-bot (prgs-merger)",
COMPLETED_ACTIONS="merged PR #900 as cccc333",
BASE_OR_MERGE_SHA="cccc333",
NEXT_ACTOR="controller",
NEXT_ACTION="verify acceptance criteria and close issue 626",
PROHIBITED_ACTIONS="reopen the PR, re-merge",
NEXT_PROMPT=MERGER_PROMPT,
)
result = assess_self_propagating_handoff(body)
self.assertTrue(result["valid"], result["reasons"])
self.assertEqual(result["next_actor"], "controller")
class ControllerBackToAuthorTests(unittest.TestCase):
"""Scenario 4: controller -> author."""
def test_request_corrections_returns_to_author(self):
result = assess_controller_decision(decision="request_corrections")
self.assertFalse(result["block"])
self.assertEqual(result["next_state"], "needs-author")
self.assertTrue(result["next_prompt_required"])
def test_return_to_actor_requires_a_named_target(self):
result = assess_controller_decision(decision="return_to_actor")
self.assertTrue(result["block"])
def test_return_to_reviewer_is_supported(self):
result = assess_controller_decision(
decision="return_to_actor", return_to="reviewer"
)
self.assertEqual(result["next_state"], "needs-review")
def test_unknown_decision_fails_closed(self):
result = assess_controller_decision(decision="looks-fine")
self.assertTrue(result["block"])
def test_controller_handoff_back_to_author_validates(self):
body = build_handoff(
WORKFLOW_STATE="needs-author",
ACTING_ROLE="controller",
ACTING_IDENTITY="controller (operator)",
COMPLETED_ACTIONS="reviewed merged work; requested corrections",
NEXT_ACTOR="author",
NEXT_ACTION="address controller corrections on issue 626",
PROHIBITED_ACTIONS="close the issue, merge",
NEXT_PROMPT=CONTROLLER_PROMPT,
)
result = assess_self_propagating_handoff(body)
self.assertTrue(result["valid"], result["reasons"])
class StaleHeadRejectionTests(unittest.TestCase):
"""Scenario 5: stale-head rejection."""
def test_changed_head_invalidates_a_merge_handoff(self):
body = build_handoff(
WORKFLOW_STATE="approved-awaiting-merge",
ACTING_ROLE="reviewer",
NEXT_ACTOR="merger",
NEXT_ACTION="merge PR #900 at approved head aaaa111",
NEXT_PROMPT=REVIEWER_PROMPT,
)
result = assess_handoff_live_state(
handoff=body,
live={"pr_head_sha": "dddd444", "pr_state": "open"},
)
self.assertTrue(result["block"])
self.assertIn("changed_pr_head", result["kinds"])
self.assertEqual(result["recovered_state"], "needs-review")
def test_stale_approval_blocks_the_merger(self):
body = build_handoff(
WORKFLOW_STATE="approved-awaiting-merge",
NEXT_ACTOR="merger",
NEXT_ACTION="merge PR #900",
HEAD_SHA="dddd444",
NEXT_PROMPT=REVIEWER_PROMPT,
)
result = assess_handoff_live_state(
handoff=body,
live={"pr_head_sha": "dddd444", "approved_head_sha": "aaaa111"},
)
self.assertTrue(result["block"])
self.assertIn("stale_approval", result["kinds"])
def test_unchanged_head_is_not_blocked(self):
result = assess_handoff_live_state(
handoff=build_handoff(),
live={
"pr_head_sha": "aaaa111",
"pr_state": "open",
"issue_state": "open",
"base_branch": "master",
"namespace_role": "reviewer",
},
)
self.assertFalse(result["block"], result["reasons"])
def test_merged_pr_recovers_to_the_controller_boundary(self):
result = assess_handoff_live_state(
handoff=build_handoff(),
live={"pr_head_sha": "aaaa111", "pr_state": "merged"},
)
self.assertIn("pr_merged", result["kinds"])
self.assertEqual(result["recovered_state"], "merged-awaiting-controller")
def test_reopened_issue_invalidates_a_complete_handoff(self):
body = build_handoff(
WORKFLOW_STATE="complete",
ACTING_ROLE="controller",
NEXT_ACTOR="none",
NEXT_ACTION="none",
NEXT_PROMPT="none",
)
result = assess_handoff_live_state(
handoff=body, live={"issue_state": "open"}
)
self.assertIn("issue_reopened", result["kinds"])
self.assertEqual(result["recovered_state"], "needs-author")
def test_foreign_lease_and_worktree_faults_are_detected(self):
result = assess_handoff_live_state(
handoff=build_handoff(),
live={
"pr_head_sha": "aaaa111",
"lease": {"status": "expired", "session_id": "other-session"},
"actor_session_id": "my-session",
"worktree": {"present": False, "dirty": True},
"namespace_role": "author",
"runtime_stale": True,
"base_branch": "dev",
"conflicting_canonical_comments": True,
},
)
for kind in (
"stale_lease",
"foreign_lease",
"missing_worktree",
"dirty_worktree",
"namespace_mismatch",
"stale_runtime",
"changed_base",
"conflicting_canonical_comments",
):
self.assertIn(kind, result["kinds"])
class BlockedInfrastructurePathTests(unittest.TestCase):
"""Scenario 6: blocked infrastructure path."""
def build(self, **overrides):
values = {
"WORKFLOW_STATE": "blocked",
"PR": "none",
"HEAD_SHA": "none",
"ACTING_ROLE": "author",
"COMPLETED_ACTIONS": "attempted native publish; MCP mutation rejected",
"BLOCKERS": "gitea_create_pr rejected: namespace unreachable",
"NEXT_ACTOR": "operator",
"NEXT_ACTION": "restore the author MCP namespace",
"PROHIBITED_ACTIONS": "raw git push, curl, force-push",
"NEXT_PROMPT": (
"Repair the author MCP namespace for "
"Scaled-Tech-Consulting/Gitea-Tools so issue 626 can publish "
"natively, then hand back to the author."
),
"WORKFLOW_FAILURE_ISSUES": "#640",
}
values.update(overrides)
return build_handoff(**values)
def test_blocked_handoff_without_pr_is_valid(self):
result = assess_self_propagating_handoff(self.build())
self.assertTrue(result["valid"], result["reasons"])
self.assertEqual(result["next_actor"], "operator")
def test_blocked_requires_a_concrete_blocker(self):
result = assess_self_propagating_handoff(self.build(BLOCKERS="none"))
self.assertTrue(result["block"])
self.assertTrue(
any("BLOCKERS" in reason for reason in result["reasons"])
)
def test_operator_is_the_only_authorized_continuation(self):
self.assertTrue(
assess_role_continuation(handoff=self.build(), actor_role="operator")[
"allowed"
]
)
self.assertTrue(
assess_role_continuation(handoff=self.build(), actor_role="merger")["block"]
)
class FinalClosureTests(unittest.TestCase):
"""Scenario 7: final successful closure."""
def build(self, **overrides):
values = {
"WORKFLOW_STATE": "complete",
"ACTING_ROLE": "controller",
"ACTING_IDENTITY": "controller (operator)",
"COMPLETED_ACTIONS": "verified acceptance criteria; closed issue 626",
"BASE_OR_MERGE_SHA": "cccc333",
"NEXT_ACTOR": "none",
"NEXT_ACTION": "none",
"PROHIBITED_ACTIONS": "reopen without new evidence",
"NEXT_PROMPT": "none",
}
values.update(overrides)
return build_handoff(**values)
def test_terminal_handoff_is_valid_without_a_next_prompt(self):
result = assess_self_propagating_handoff(self.build())
self.assertTrue(result["valid"], result["reasons"])
self.assertTrue(result["terminal"])
def test_terminal_handoff_must_not_manufacture_more_work(self):
result = assess_self_propagating_handoff(
self.build(NEXT_PROMPT=CONTROLLER_PROMPT)
)
self.assertTrue(result["block"])
self.assertTrue(
any("must not carry a NEXT_PROMPT" in r for r in result["reasons"])
)
def test_no_role_may_continue_a_complete_workflow(self):
result = assess_role_continuation(handoff=self.build(), actor_role="author")
self.assertTrue(result["block"])
def test_controller_acceptance_requires_full_closure_proof(self):
partial = assess_controller_decision(
decision="accept",
closure_proof={"acceptance_criteria_satisfied": True},
)
self.assertTrue(partial["block"])
self.assertEqual(partial["next_state"], "merged-awaiting-controller")
full = assess_controller_decision(
decision="accept",
closure_proof={
"acceptance_criteria_satisfied": True,
"cleanup_complete": True,
"canonical_final_state_posted": True,
"issue_closed_through_workflow": True,
},
)
self.assertFalse(full["block"])
self.assertEqual(full["next_state"], "complete")
self.assertFalse(full["next_prompt_required"])
class IncompleteHandoffRejectionTests(unittest.TestCase):
"""Scenario 8: incomplete handoff rejection."""
def test_missing_block_is_rejected(self):
result = assess_self_propagating_handoff("Work is done, ping the reviewer.")
self.assertTrue(result["block"])
self.assertFalse(result["present"])
def test_missing_field_is_rejected(self):
body = build_handoff()
body = "\n".join(
line for line in body.splitlines() if not line.startswith("MUTATION_LEDGER:")
)
result = assess_self_propagating_handoff(body)
self.assertTrue(result["block"])
self.assertIn("MUTATION_LEDGER", result["missing_fields"])
def test_placeholder_field_is_rejected(self):
result = assess_self_propagating_handoff(
build_handoff(VALIDATION_EVIDENCE="TBD")
)
self.assertTrue(result["block"])
def test_stub_next_prompt_is_rejected(self):
result = assess_self_propagating_handoff(build_handoff(NEXT_PROMPT="review it"))
self.assertTrue(result["block"])
self.assertTrue(
any("ready-to-run" in reason for reason in result["reasons"])
)
def test_prompt_depending_on_outside_chat_is_rejected(self):
prompt = (
"Continue issue 626 on Scaled-Tech-Consulting/Gitea-Tools using the "
"previous chat for the missing details."
)
result = assess_thread_recoverability(build_handoff(NEXT_PROMPT=prompt))
self.assertTrue(result["block"])
def test_prompt_must_name_repository_and_issue(self):
prompt = (
"Please review the pull request at the current head and submit an "
"independent verdict when validation passes."
)
result = assess_thread_recoverability(build_handoff(NEXT_PROMPT=prompt))
self.assertTrue(result["block"])
def test_self_contained_prompt_is_recoverable(self):
self.assertFalse(assess_thread_recoverability(build_handoff())["block"])
def test_chat_only_report_is_not_durable(self):
result = assess_durable_state_update(
handoff_text=build_handoff(),
posted_comment_id=None,
canonical_state_posted=False,
)
self.assertTrue(result["block"])
self.assertEqual(len(result["reasons"]), 2)
def test_posted_handoff_is_durable(self):
result = assess_durable_state_update(
handoff_text=build_handoff(),
posted_comment_id=13550,
canonical_state_posted=True,
)
self.assertTrue(result["durable"], result["reasons"])
class WorkflowFailureEscalationTests(unittest.TestCase):
"""Scenario 9: duplicate workflow-failure issue handling."""
def failure(self, **overrides):
values = {
"signature": "lease-cleanup-internal-error",
"classification": "mcp-tool-defect",
"linked_issue": "718",
"temporary_impact": "lease cleanup unavailable this session",
"next_valid_actor": "operator",
"recovery_prompt": "restart the namespace and re-run lease cleanup",
}
values.update(overrides)
return values
def test_complete_failure_record_passes(self):
result = assess_workflow_failure_escalation(
failures=[self.failure()], active_issue_number=626
)
self.assertTrue(result["escalated"], result["reasons"])
def test_incomplete_failure_record_fails_closed(self):
result = assess_workflow_failure_escalation(
failures=[self.failure(recovery_prompt="")], active_issue_number=626
)
self.assertTrue(result["block"])
def test_folding_into_the_active_issue_is_rejected(self):
result = assess_workflow_failure_escalation(
failures=[self.failure(linked_issue="626")], active_issue_number=626
)
self.assertTrue(result["block"])
self.assertTrue(
any("folded into the active work item" in r for r in result["reasons"])
)
def test_known_signature_reuses_the_existing_issue(self):
result = assess_workflow_failure_escalation(
failures=[self.failure()],
active_issue_number=626,
existing_failure_issues=[
{"signature": "lease-cleanup-internal-error", "number": 718}
],
)
self.assertTrue(result["escalated"], result["reasons"])
self.assertEqual(result["reused_issues"], [
{"signature": "lease-cleanup-internal-error", "issue": "718"}
])
def test_duplicate_issue_for_known_signature_is_rejected(self):
result = assess_workflow_failure_escalation(
failures=[self.failure(linked_issue="799")],
active_issue_number=626,
existing_failure_issues=[
{"signature": "lease-cleanup-internal-error", "number": 718}
],
)
self.assertTrue(result["block"])
self.assertTrue(
any("reuse the existing issue #718" in r for r in result["reasons"])
)
def test_same_signature_twice_in_one_session_is_rejected(self):
result = assess_workflow_failure_escalation(
failures=[self.failure(), self.failure()], active_issue_number=626
)
self.assertTrue(result["block"])
def test_no_failures_is_not_an_error(self):
result = assess_workflow_failure_escalation(
failures=[], active_issue_number=626
)
self.assertTrue(result["escalated"])
class FinalReportIntegrationTests(unittest.TestCase):
def test_report_without_the_protocol_is_not_applicable(self):
result = assess_final_report_self_propagating_handoff("## Controller Handoff\n")
self.assertFalse(result["applicable"])
self.assertFalse(result["block"])
def test_report_with_a_complete_handoff_passes(self):
result = assess_final_report_self_propagating_handoff(build_handoff())
self.assertTrue(result["applicable"])
self.assertFalse(result["block"], result["reasons"])
def test_report_with_an_incomplete_handoff_blocks(self):
result = assess_final_report_self_propagating_handoff(
build_handoff(NEXT_ACTION="")
)
self.assertTrue(result["block"])
def test_validator_blocks_an_incomplete_handoff_in_a_work_issue_report(self):
report = build_handoff(MUTATION_LEDGER="TBD")
result = assess_final_report_validator(report, "work_issue")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
finding["rule_id"] == "shared.self_propagating_handoff"
for finding in result["findings"]
)
)
def test_validator_ignores_reports_that_predate_the_protocol(self):
result = assess_final_report_validator("plain legacy report", "work_issue")
self.assertFalse(
any(
finding["rule_id"] == "shared.self_propagating_handoff"
for finding in result["findings"]
)
)
if __name__ == "__main__":
unittest.main()
+665
View File
@@ -0,0 +1,665 @@
"""Tests for the Sentry → Gitea incident bridge (#607).
Covers AC9: create, update, dedupe, closed-linked issue, redaction,
pagination, missing token, unavailable Sentry server, and self-hosted base URL.
No live Sentry: the HTTP layer is injected via ``http_fn``.
"""
from __future__ import annotations
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
import json
import os
import tempfile
import unittest
import unittest.mock
import urllib.error
import urllib.request
from control_plane_db import ControlPlaneDB
from incident_bridge import (
OUTCOME_CREATED,
OUTCOME_PREVIEW,
OUTCOME_UPDATED,
ProjectMapping,
)
import sentry_incident_bridge as bridge
BASE_URL = "https://sentry.prgs.cc"
SENTRY_ORG = "prgs"
SENTRY_PROJECT = "gitea-tools-mcp"
GITEA_ORG = "Scaled-Tech-Consulting"
GITEA_REPO = "Gitea-Tools"
TOKEN = "synthetic-test-token"
def _config(**kwargs) -> bridge.SentryBridgeConfig:
base = dict(
base_url=BASE_URL,
org=SENTRY_ORG,
project=SENTRY_PROJECT,
lookback="24h",
min_events_for_issue=2,
bridge_enabled=True,
)
base.update(kwargs)
return bridge.SentryBridgeConfig(**base)
def _mapping() -> ProjectMapping:
return ProjectMapping(
name="gitea-tools-mcp",
provider="sentry",
monitor_base_url=BASE_URL,
monitor_org=SENTRY_ORG,
monitor_project=SENTRY_PROJECT,
gitea_org=GITEA_ORG,
gitea_repo=GITEA_REPO,
default_labels=("type:bug", "observability", "sentry", "status:ready"),
)
def _raw_issue(issue_id: str = "4001", **kwargs) -> dict:
payload = {
"id": issue_id,
"shortId": "GITEA-TOOLS-1A",
"title": "RuntimeError: lease acquisition failed",
"culprit": "lease_lifecycle in acquire",
"level": "error",
"status": "unresolved",
"count": "7",
"userCount": 1,
"firstSeen": "2026-07-18T04:11:02.000000Z",
"lastSeen": "2026-07-19T22:40:17.000000Z",
"permalink": f"{BASE_URL}/organizations/{SENTRY_ORG}/issues/{issue_id}/",
"metadata": {"type": "RuntimeError", "value": "lease acquisition failed"},
}
payload.update(kwargs)
return payload
def _raw_event(event_id: str = "ev-1", **kwargs) -> dict:
payload = {
"eventID": event_id,
"message": "lease acquisition failed",
"dateCreated": "2026-07-19T22:40:17.000000Z",
"platform": "python",
"environment": "prod",
"release": "1.2.3",
"tags": [{"key": "role", "value": "author"}],
}
payload.update(kwargs)
return payload
class FakeHttp:
"""Routes synthetic Sentry responses and records requested URLs."""
def __init__(self, routes: list[tuple[int, object, dict[str, str]]] | None = None):
# routes: sequential responses for the issues endpoint
self.routes = routes or []
self.calls: list[str] = []
self.headers_seen: list[dict[str, str]] = []
self.issue_page = 0
def __call__(self, url, headers, timeout):
self.calls.append(url)
self.headers_seen.append(dict(headers))
if "/events/" in url:
return 200, json.dumps([_raw_event()]).encode(), {}
if self.routes:
index = min(self.issue_page, len(self.routes) - 1)
self.issue_page += 1
status, payload, resp_headers = self.routes[index]
body = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
return status, body, resp_headers
return 200, json.dumps([_raw_issue()]).encode(), {}
class SentryBridgeTestCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
self.db = ControlPlaneDB(self.db_path)
self.created: list[dict] = []
self.comments: list[dict] = []
self._next_issue_number = 900
self._next_comment_id = 5000
def _create_issue_fn(self):
def create_fn(title, body, labels, g_org, g_repo):
self._next_issue_number += 1
self.created.append(
{
"title": title,
"body": body,
"labels": list(labels),
"org": g_org,
"repo": g_repo,
"number": self._next_issue_number,
}
)
return {"success": True, "number": self._next_issue_number}
return create_fn
def _comment_issue_fn(self):
def comment_fn(issue_number, body, g_org, g_repo):
self._next_comment_id += 1
self.comments.append(
{
"issue_number": issue_number,
"body": body,
"org": g_org,
"repo": g_repo,
"comment_id": self._next_comment_id,
}
)
return {"success": True, "comment_id": self._next_comment_id}
return comment_fn
def _link(self, issue_id: str = "4001"):
return self.db.get_incident_link_by_provider(
provider="sentry",
provider_issue_id=issue_id,
provider_base_url=BASE_URL,
provider_org=SENTRY_ORG,
provider_project=SENTRY_PROJECT,
)
def _watchdog(self, http, *, apply=True, config=None, **kwargs):
return bridge.watchdog(
self.db,
config or _config(),
token=TOKEN,
apply=apply,
mappings=[_mapping()],
http_fn=http,
create_issue_fn=self._create_issue_fn(),
# Always supplied, including dry runs: the bridge itself must
# withhold the comment when apply=False (AC4 + AC8).
comment_issue_fn=self._comment_issue_fn(),
**kwargs,
)
class TestConfigAndSelfHosted(SentryBridgeTestCase):
def test_self_hosted_base_url_is_used_and_flagged(self):
config = _config()
self.assertTrue(config.as_dict()["self_hosted"])
http = FakeHttp()
bridge.list_issues(config, token=TOKEN, http_fn=http)
self.assertTrue(http.calls[0].startswith(f"{BASE_URL}/api/0/projects/"))
self.assertIn(f"/projects/{SENTRY_ORG}/{SENTRY_PROJECT}/issues/", http.calls[0])
self.assertIn("statsPeriod=24h", http.calls[0])
def test_config_never_exposes_token(self):
config = bridge.load_bridge_config(
{
bridge.ENV_BASE_URL: BASE_URL,
bridge.ENV_ORG: SENTRY_ORG,
bridge.ENV_PROJECT: SENTRY_PROJECT,
bridge.ENV_AUTH_TOKEN: "super-secret-value",
}
)
serialized = json.dumps(config.as_dict())
self.assertNotIn("super-secret-value", serialized)
self.assertNotIn("token", serialized.lower())
def test_invalid_lookback_falls_back_to_default(self):
config = bridge.load_bridge_config({bridge.ENV_LOOKBACK: "not-a-window"})
self.assertEqual(config.lookback, bridge.DEFAULT_LOOKBACK)
class TestMissingTokenAndUnavailable(SentryBridgeTestCase):
def test_missing_token_fails_closed_without_http_call(self):
http = FakeHttp()
with self.assertRaises(bridge.SentryApiError) as ctx:
bridge.list_issues(_config(), token="", http_fn=http)
self.assertEqual(ctx.exception.kind, bridge.ERROR_MISSING_TOKEN)
self.assertEqual(http.calls, [], "no HTTP call may be made without a token")
def test_unconfigured_project_fails_closed(self):
with self.assertRaises(bridge.SentryApiError) as ctx:
bridge.list_issues(_config(project=""), token=TOKEN, http_fn=FakeHttp())
self.assertEqual(ctx.exception.kind, bridge.ERROR_NOT_CONFIGURED)
def test_unauthorized_status_maps_to_missing_token(self):
http = FakeHttp(routes=[(401, {"detail": "Invalid token"}, {})])
with self.assertRaises(bridge.SentryApiError) as ctx:
bridge.list_issues(_config(), token=TOKEN, http_fn=http)
self.assertEqual(ctx.exception.kind, bridge.ERROR_MISSING_TOKEN)
def test_server_error_maps_to_unavailable(self):
http = FakeHttp(routes=[(502, {"detail": "bad gateway"}, {})])
with self.assertRaises(bridge.SentryApiError) as ctx:
bridge.list_issues(_config(), token=TOKEN, http_fn=http)
self.assertEqual(ctx.exception.kind, bridge.ERROR_UNAVAILABLE)
def test_urlerror_from_default_handler_maps_to_unavailable(self):
"""The real urllib handler must translate URLError, not leak it."""
def boom(request, timeout=None):
raise urllib.error.URLError("connection refused")
with unittest.mock.patch.object(urllib.request, "urlopen", boom):
with self.assertRaises(bridge.SentryApiError) as ctx:
bridge._default_http_fn("https://sentry.prgs.cc/api/0/x/", {}, 1.0)
self.assertEqual(ctx.exception.kind, bridge.ERROR_UNAVAILABLE)
def test_watchdog_reports_unavailable_without_mutating(self):
def failing(url, headers, timeout):
raise bridge.SentryApiError(
"Sentry unreachable", kind=bridge.ERROR_UNAVAILABLE
)
result = self._watchdog(failing)
self.assertFalse(result["success"])
self.assertEqual(result["error_kind"], bridge.ERROR_UNAVAILABLE)
self.assertEqual(self.created, [], "no Gitea issue on Sentry outage")
def test_invalid_json_fails_closed(self):
http = FakeHttp(routes=[(200, b"<html>not json</html>", {})])
with self.assertRaises(bridge.SentryApiError) as ctx:
bridge.list_issues(_config(), token=TOKEN, http_fn=http)
self.assertEqual(ctx.exception.kind, bridge.ERROR_INVALID_RESPONSE)
class TestPagination(SentryBridgeTestCase):
def test_link_header_cursor_is_followed(self):
page1 = (
200,
[_raw_issue("4001")],
{
"link": (
f'<{BASE_URL}/api/0/x/?cursor=c1>; rel="previous"; results="false", '
f'<{BASE_URL}/api/0/x/?cursor=c2>; rel="next"; results="true"; cursor="c2"'
)
},
)
page2 = (
200,
[_raw_issue("4002")],
{
"link": (
f'<{BASE_URL}/api/0/x/?cursor=c3>; rel="next"; '
'results="false"; cursor="c3"'
)
},
)
http = FakeHttp(routes=[page1, page2])
result = bridge.list_issues(_config(), token=TOKEN, http_fn=http)
self.assertEqual(result["pages_fetched"], 2)
self.assertEqual([i["id"] for i in result["issues"]], ["4001", "4002"])
self.assertTrue(result["inventory_complete"])
self.assertIn("cursor=c2", http.calls[1])
def test_max_pages_caps_traversal_and_reports_incomplete(self):
page = (
200,
[_raw_issue("4001")],
{"link": f'<{BASE_URL}/x>; rel="next"; results="true"; cursor="cN"'},
)
http = FakeHttp(routes=[page])
result = bridge.list_issues(_config(), token=TOKEN, http_fn=http, max_pages=3)
self.assertEqual(result["pages_fetched"], 3)
self.assertFalse(result["inventory_complete"])
def test_parse_next_cursor_ignores_exhausted_results(self):
self.assertIsNone(
bridge.parse_next_cursor('<u>; rel="next"; results="false"; cursor="c"')
)
self.assertEqual(
bridge.parse_next_cursor('<u>; rel="next"; results="true"; cursor="c9"'),
"c9",
)
self.assertIsNone(bridge.parse_next_cursor(None))
class TestRedaction(SentryBridgeTestCase):
def test_secrets_and_paths_are_scrubbed(self):
raw = _raw_issue(
title="RuntimeError: token=abc123supersecret failed",
culprit="/Users/jasonwalker/Development/Gitea-Tools/lease_lifecycle.py",
metadata={"type": "RuntimeError", "value": "password=hunter2"},
)
sanitized = bridge.sanitize_issue(raw)
blob = json.dumps(sanitized)
self.assertNotIn("abc123supersecret", blob)
self.assertNotIn("hunter2", blob)
self.assertNotIn("/Users/jasonwalker", blob)
self.assertIn("[REDACTED]", sanitized["title"])
def test_permalink_with_embedded_credentials_is_dropped(self):
raw = _raw_issue(permalink="https://user:[email protected]/issues/4001/")
self.assertIsNone(bridge.sanitize_issue(raw)["permalink"])
def test_sensitive_event_tags_are_removed(self):
event = _raw_event(
tags=[
{"key": "authorization", "value": "Bearer abc123secrettoken"},
{"key": "role", "value": "author"},
]
)
sanitized = bridge.sanitize_event(event)
blob = json.dumps(sanitized)
self.assertNotIn("abc123secrettoken", blob)
self.assertEqual(sanitized["tags"].get("role"), "author")
def test_token_never_appears_in_watchdog_output(self):
result = self._watchdog(FakeHttp())
self.assertNotIn(TOKEN, json.dumps(result))
def test_issue_without_id_fails_closed(self):
with self.assertRaises(bridge.SentryApiError) as ctx:
bridge.sanitize_issue({"title": "no id"})
self.assertEqual(ctx.exception.kind, bridge.ERROR_INVALID_RESPONSE)
class TestCreateUpdateDedupe(SentryBridgeTestCase):
def test_dry_run_creates_nothing(self):
result = self._watchdog(FakeHttp(), apply=False)
self.assertTrue(result["success"])
self.assertEqual(result["reconciled"], 1)
self.assertEqual(result["results"][0]["outcome"], OUTCOME_PREVIEW)
self.assertEqual(self.created, [], "dry-run must not create Gitea issues")
def test_apply_creates_one_durable_gitea_issue(self):
result = self._watchdog(FakeHttp())
self.assertTrue(result["success"])
self.assertEqual(result["results"][0]["outcome"], OUTCOME_CREATED)
self.assertEqual(len(self.created), 1)
created = self.created[0]
self.assertEqual(created["org"], GITEA_ORG)
self.assertEqual(created["repo"], GITEA_REPO)
self.assertIn("sentry", created["labels"])
# AC5: body carries the Sentry id and the first-seen window.
self.assertIn("4001", created["body"])
self.assertIn("2026-07-18T04:11:02", created["body"])
def test_repeat_scan_dedupes_to_a_single_issue(self):
first = self._watchdog(FakeHttp())
second = self._watchdog(FakeHttp())
self.assertEqual(first["results"][0]["outcome"], OUTCOME_CREATED)
self.assertEqual(second["results"][0]["outcome"], OUTCOME_UPDATED)
self.assertEqual(len(self.created), 1, "recurrence must not create a duplicate")
def test_recurrence_updates_link_event_count(self):
self._watchdog(FakeHttp())
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
result = self._watchdog(recurring)
self.assertEqual(result["results"][0]["outcome"], OUTCOME_UPDATED)
self.assertEqual(self._link()["event_count"], 42)
def test_recurrence_posts_a_comment_on_the_second_scan(self):
"""AC4: continued Sentry events comment on the linked Gitea issue."""
first = self._watchdog(FakeHttp())
self.assertEqual(first["results"][0]["outcome"], OUTCOME_CREATED)
self.assertEqual(self.comments, [], "creation must not post a recurrence comment")
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
second = self._watchdog(recurring)
self.assertEqual(second["results"][0]["outcome"], OUTCOME_UPDATED)
self.assertEqual(len(self.comments), 1, "recurrence must post exactly one comment")
comment = self.comments[0]
linked_number = int(self._link()["gitea_issue_number"])
self.assertEqual(comment["issue_number"], linked_number)
self.assertEqual(comment["org"], GITEA_ORG)
self.assertEqual(comment["repo"], GITEA_REPO)
# AC5 fields carried on the recurrence record.
self.assertIn("4001", comment["body"])
self.assertIn("42", comment["body"])
self.assertIn("recurrence_basis", comment["body"])
reported = second["results"][0]["recurrence_comment"]
self.assertTrue(reported["posted"])
self.assertEqual(reported["comment_id"], comment["comment_id"])
self.assertEqual(len(self.created), 1, "recurrence must not create a duplicate issue")
def test_dry_run_scan_posts_no_recurrence_comment(self):
"""AC4 + AC8: dry run never comments, even on a linked recurrence."""
self._watchdog(FakeHttp())
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
result = self._watchdog(recurring, apply=False)
self.assertEqual(result["results"][0]["outcome"], OUTCOME_PREVIEW)
self.assertEqual(self.comments, [], "dry-run must not post recurrence comments")
def test_repeat_scan_without_new_events_posts_no_comment(self):
"""A scan that observes no new events must stay silent."""
self._watchdog(FakeHttp())
result = self._watchdog(FakeHttp())
self.assertEqual(result["results"][0]["outcome"], OUTCOME_UPDATED)
self.assertEqual(self.comments, [], "unchanged event state must not comment")
self.assertFalse(result["results"][0]["recurrence_comment"]["posted"])
def test_recurrence_comment_failure_keeps_the_link_durable(self):
"""A failed comment must not roll back or block the incident_links row."""
self._watchdog(FakeHttp())
def failing_comment(issue_number, body, g_org, g_repo):
raise RuntimeError("gitea comment route unavailable")
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
result = bridge.watchdog(
self.db,
_config(),
token=TOKEN,
apply=True,
mappings=[_mapping()],
http_fn=recurring,
create_issue_fn=self._create_issue_fn(),
comment_issue_fn=failing_comment,
)
entry = result["results"][0]
self.assertEqual(entry["outcome"], OUTCOME_UPDATED)
self.assertFalse(entry["recurrence_comment"]["posted"])
self.assertEqual(self._link()["event_count"], 42, "link must still be updated")
def test_recurrence_comment_is_redacted(self):
"""AC2: recurrence comments pass through the same redaction path."""
self._watchdog(FakeHttp())
recurring = FakeHttp(
routes=[
(
200,
[
_raw_issue(
"4001",
count="42",
metadata={
"type": "RuntimeError",
"value": "token=abc123supersecret",
},
)
],
{},
)
]
)
self._watchdog(recurring)
self.assertEqual(len(self.comments), 1)
body = self.comments[0]["body"]
self.assertNotIn("abc123supersecret", body)
self.assertNotIn(TOKEN, body)
def test_link_survives_a_new_db_handle(self):
"""AC6: bridge mapping survives process restarts."""
self._watchdog(FakeHttp())
reopened = ControlPlaneDB(self.db_path)
link = reopened.get_incident_link_by_provider(
provider="sentry",
provider_issue_id="4001",
provider_base_url=BASE_URL,
provider_org=SENTRY_ORG,
provider_project=SENTRY_PROJECT,
)
self.assertIsNotNone(link)
self.assertEqual(int(link["gitea_issue_number"]), 901)
def test_resolved_issue_is_not_recreated_or_reopened(self):
"""AC7: a resolved Sentry issue never creates or reopens Gitea work."""
self._watchdog(FakeHttp())
linked_number = int(self._link()["gitea_issue_number"])
closed = FakeHttp(routes=[(200, [_raw_issue("4001", status="resolved")], {})])
result = self._watchdog(closed)
self.assertEqual(result["skipped"], 1)
self.assertEqual(result["results"][0]["action"], bridge.ACTION_SKIPPED_STATUS)
self.assertEqual(len(self.created), 1)
self.assertEqual(linked_number, 901)
class TestPolicyGates(SentryBridgeTestCase):
def test_below_threshold_issue_is_skipped(self):
http = FakeHttp(routes=[(200, [_raw_issue("4001", count="1")], {})])
result = self._watchdog(http)
self.assertEqual(result["skipped"], 1)
self.assertEqual(result["results"][0]["action"], bridge.ACTION_SKIPPED_THRESHOLD)
self.assertEqual(self.created, [])
def test_apply_refused_when_bridge_disabled(self):
result = self._watchdog(FakeHttp(), config=_config(bridge_enabled=False))
self.assertFalse(result["success"])
self.assertEqual(result["error_kind"], bridge.ERROR_BRIDGE_DISABLED)
self.assertEqual(self.created, [])
def test_dry_run_allowed_while_bridge_disabled(self):
result = self._watchdog(
FakeHttp(), apply=False, config=_config(bridge_enabled=False)
)
self.assertTrue(result["success"])
def test_raw_incident_is_never_assignable_work(self):
result = self._watchdog(FakeHttp())
self.assertFalse(result["raw_incident_assignable"])
self.assertEqual(result["durable_work_system"], "gitea_issues")
def test_reconcile_failure_is_isolated_and_redacted(self):
def exploding(db, **kwargs):
raise RuntimeError("token=abc123 boom")
result = self._watchdog(FakeHttp(), reconcile_fn=exploding)
self.assertFalse(result["success"])
self.assertEqual(result["failed"], 1)
self.assertNotIn("abc123", json.dumps(result))
class TestObservationMapping(SentryBridgeTestCase):
def test_observation_carries_provider_identity_and_targets(self):
issue = bridge.sanitize_issue(_raw_issue())
event = bridge.sanitize_event(_raw_event())
obs = bridge.observation_from_issue(
issue,
_config(),
gitea_org=GITEA_ORG,
gitea_repo=GITEA_REPO,
latest_event=event,
)
self.assertEqual(obs["provider"], "sentry")
self.assertEqual(obs["provider_base_url"], BASE_URL)
self.assertEqual(obs["provider_issue_id"], "4001")
self.assertEqual(obs["event_count"], 7)
self.assertEqual(obs["environment"], "prod")
self.assertEqual(obs["gitea_repo"], GITEA_REPO)
self.assertTrue(_mapping().matches_observation(obs))
def test_events_fetch_returns_latest_first(self):
result = bridge.get_issue_events(
_config(), "4001", token=TOKEN, http_fn=FakeHttp()
)
self.assertEqual(result["count"], 1)
self.assertEqual(result["latest_event"]["environment"], "prod")
class TestMcpToolWrappers(unittest.TestCase):
"""The registered MCP tools must fail closed, never raise, never leak."""
def setUp(self):
# Read-capable profile, fully configured Sentry target, but
# deliberately no SENTRY_AUTH_TOKEN — the token gap is the only fault.
self.env = shared_mutation_env(
"test-author-prgs",
**{
bridge.ENV_BASE_URL: BASE_URL,
bridge.ENV_ORG: SENTRY_ORG,
bridge.ENV_PROJECT: SENTRY_PROJECT,
},
)
self.env.pop(bridge.ENV_AUTH_TOKEN, None)
def _server(self):
import gitea_mcp_server
return gitea_mcp_server
def test_all_five_tools_are_registered(self):
import asyncio
tools = asyncio.run(self._server().mcp.list_tools())
registered = {t.name for t in tools if t.name.startswith("gitea_sentry_")}
self.assertEqual(
registered,
{
"gitea_sentry_list_issues",
"gitea_sentry_get_issue_events",
"gitea_sentry_reconcile_issue",
"gitea_sentry_link_gitea_issue",
"gitea_sentry_watchdog",
},
)
def test_list_issues_without_token_fails_closed(self):
with unittest.mock.patch.dict(os.environ, self.env, clear=True):
result = self._server().gitea_sentry_list_issues()
self.assertFalse(result["success"])
self.assertEqual(result.get("error_kind"), bridge.ERROR_MISSING_TOKEN)
self.assertEqual(result["issues"], [])
def test_get_issue_events_without_token_fails_closed(self):
with unittest.mock.patch.dict(os.environ, self.env, clear=True):
result = self._server().gitea_sentry_get_issue_events("4001")
self.assertFalse(result["success"])
self.assertEqual(result.get("error_kind"), bridge.ERROR_MISSING_TOKEN)
def test_reconcile_without_token_fails_closed_without_mutation(self):
with unittest.mock.patch.dict(os.environ, self.env, clear=True):
result = self._server().gitea_sentry_reconcile_issue("4001", apply=True)
self.assertFalse(result["success"])
self.assertFalse(result["raw_incident_assignable"])
self.assertEqual(result.get("error_kind"), bridge.ERROR_MISSING_TOKEN)
def test_watchdog_without_token_fails_closed(self):
with unittest.mock.patch.dict(os.environ, self.env, clear=True):
result = self._server().gitea_sentry_watchdog()
self.assertFalse(result["success"])
self.assertEqual(result.get("error_kind"), bridge.ERROR_MISSING_TOKEN)
def test_unconfigured_target_reports_not_configured_before_token(self):
env = {k: v for k, v in self.env.items() if not k.startswith("SENTRY_")}
with unittest.mock.patch.dict(os.environ, env, clear=True):
result = self._server().gitea_sentry_list_issues()
self.assertFalse(result["success"])
self.assertEqual(result.get("error_kind"), bridge.ERROR_NOT_CONFIGURED)
def test_tool_output_never_contains_a_token_value(self):
env = dict(self.env)
env[bridge.ENV_AUTH_TOKEN] = "leaky-token-value"
with unittest.mock.patch.dict(os.environ, env, clear=True):
result = self._server().gitea_sentry_list_issues()
self.assertNotIn("leaky-token-value", json.dumps(result))
if __name__ == "__main__":
unittest.main()
+603
View File
@@ -0,0 +1,603 @@
"""Tests for the stable-control runtime mode gates (#615).
Covers acceptance criteria 6-11: runtime mode + SHA reporting, the fail-closed
mutation gates (dev-test targeting production, unknown runtime, dirty stable
checkout, dev-worktree launch, unsafe alignment), per-namespace post-flap
re-proving, promotion-record completeness, and the policy statements that keep
normal sessions from restarting the stable MCP runtime.
"""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
import stable_control_runtime as scr # noqa: E402
SHA_A = "a" * 40
SHA_B = "b" * 40
STABLE_ROOT = "/Users/dev/Development/Gitea-Tools"
DEV_WORKTREE_ROOT = "/Users/dev/Development/Gitea-Tools/branches/issue-615-work"
def stable_report(**overrides):
"""A healthy stable-control runtime report, overridable per test."""
base = dict(
process_root=STABLE_ROOT,
checkout_branch="master",
runtime_head=SHA_A,
active_task_workspace=STABLE_ROOT,
canonical_repository_root=STABLE_ROOT,
repository_slug="Scaled-Tech-Consulting/Gitea-Tools",
profile="prgs-author",
authenticated_identity="jcwalker3",
dirty_files=[],
workspace_roots_aligned=True,
)
base.update(overrides)
return scr.build_runtime_report(**base)
class TestClassifyRuntimeMode(unittest.TestCase):
def test_stable_branch_checkout_is_stable_control(self):
res = scr.classify_runtime_mode(
process_root=STABLE_ROOT, checkout_branch="master")
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE)
self.assertFalse(res["dev_worktree_launched"])
def test_main_and_dev_are_also_stable(self):
for branch in ("main", "dev"):
res = scr.classify_runtime_mode(
process_root=STABLE_ROOT, checkout_branch=branch)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE, branch)
def test_branches_worktree_launch_is_dev_test(self):
res = scr.classify_runtime_mode(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_DEV_TEST)
self.assertTrue(res["dev_worktree_launched"])
def test_feature_branch_outside_branches_is_still_dev_test(self):
res = scr.classify_runtime_mode(
process_root="/Users/dev/Development/scratch-clone",
checkout_branch="feat/experiment",
)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_DEV_TEST)
self.assertFalse(res["dev_worktree_launched"])
def test_unresolvable_root_is_unknown(self):
res = scr.classify_runtime_mode(process_root=None, checkout_branch=None)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
def test_non_git_root_is_unknown(self):
res = scr.classify_runtime_mode(
process_root="/opt/gitea-tools-release",
checkout_branch=None,
is_git_checkout=False,
)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
def test_detached_head_is_unknown(self):
res = scr.classify_runtime_mode(
process_root=STABLE_ROOT, checkout_branch=None)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
def test_operator_declaration_wins_over_inference(self):
res = scr.classify_runtime_mode(
process_root="/opt/gitea-tools-release",
checkout_branch=None,
is_git_checkout=False,
declared_mode=scr.RUNTIME_MODE_STABLE,
)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE)
self.assertTrue(res["declared"])
def test_invalid_declaration_is_ignored(self):
with patch.dict(os.environ, {scr.ENV_RUNTIME_MODE: "production-ish"}):
self.assertIsNone(scr.declared_runtime_mode())
def test_valid_declaration_is_read_from_env(self):
with patch.dict(os.environ, {scr.ENV_RUNTIME_MODE: "dev-test"}):
self.assertEqual(scr.declared_runtime_mode(), scr.RUNTIME_MODE_DEV_TEST)
class TestRuntimeReport(unittest.TestCase):
"""Acceptance criterion 6: runtime mode and SHA reporting."""
def test_report_carries_every_required_field(self):
report = stable_report()
for field in (
"runtime_mode",
"runtime_git_sha",
"runtime_branch",
"runtime_checkout_path",
"mcp_process_root",
"active_task_workspace",
"repository_slug",
"profile",
"authenticated_identity",
"dirty_files",
"workspace_roots_aligned",
"real_mutations_allowed",
):
self.assertIn(field, report, field)
def test_report_records_the_runtime_sha(self):
self.assertEqual(stable_report()["runtime_git_sha"], SHA_A)
def test_format_summarises_mode_sha_and_branch(self):
summary = scr.format_runtime_mode(stable_report())
self.assertIn(scr.RUNTIME_MODE_STABLE, summary)
self.assertIn(SHA_A[:12], summary)
self.assertIn("master", summary)
class TestMutationGate(unittest.TestCase):
"""Acceptance criterion 7: fail-closed mutation gates."""
def test_stable_healthy_runtime_allows_real_mutations(self):
report = stable_report()
gate = scr.assess_runtime_mutation_gate(report)
self.assertFalse(gate["block"])
self.assertEqual(gate["reasons"], [])
self.assertTrue(report["real_mutations_allowed"])
def test_dev_test_runtime_blocks_real_production_mutations(self):
report = stable_report(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
active_task_workspace=DEV_WORKTREE_ROOT,
)
gate = scr.assess_runtime_mutation_gate(report)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_DEV_TEST_PRODUCTION, gate["blocker_kinds"])
self.assertFalse(report["real_mutations_allowed"])
def test_dev_test_runtime_may_mutate_a_non_production_target(self):
report = stable_report(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
)
gate = scr.assess_runtime_mutation_gate(
report, target_is_production=False)
self.assertFalse(gate["block"])
def test_unknown_runtime_blocks_mutations(self):
report = stable_report(checkout_branch=None)
gate = scr.assess_runtime_mutation_gate(report)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_UNKNOWN_RUNTIME, gate["blocker_kinds"])
def test_unknown_runtime_blocks_even_a_non_production_target(self):
report = stable_report(checkout_branch=None)
gate = scr.assess_runtime_mutation_gate(
report, target_is_production=False)
self.assertTrue(gate["block"])
def test_dirty_stable_runtime_blocks_mutations(self):
report = stable_report(dirty_files=["gitea_mcp_server.py"])
gate = scr.assess_runtime_mutation_gate(report)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_DIRTY_STABLE_RUNTIME, gate["blocker_kinds"])
self.assertTrue(
any("dirty" in reason for reason in gate["reasons"]))
def test_dev_worktree_launch_is_reported_as_its_own_blocker(self):
report = stable_report(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
)
gate = scr.assess_runtime_mutation_gate(report)
self.assertIn(scr.BLOCKER_DEV_WORKTREE_LAUNCH, gate["blocker_kinds"])
def test_unsafe_workspace_alignment_blocks_mutations(self):
report = stable_report(workspace_roots_aligned=False)
gate = scr.assess_runtime_mutation_gate(report)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_UNSAFE_ALIGNMENT, gate["blocker_kinds"])
def test_unknown_alignment_does_not_block(self):
report = stable_report(workspace_roots_aligned=None)
self.assertFalse(scr.assess_runtime_mutation_gate(report)["block"])
def test_env_escape_hatch_disables_the_gate(self):
report = stable_report(checkout_branch=None)
with patch.dict(os.environ, {scr.ENV_DISABLE: "1"}):
gate = scr.assess_runtime_mutation_gate(report)
self.assertFalse(gate["block"])
self.assertTrue(gate["gate_disabled"])
def test_block_reasons_helper_matches_the_gate(self):
report = stable_report(checkout_branch=None)
self.assertEqual(
scr.runtime_block_reasons(report),
scr.assess_runtime_mutation_gate(report)["reasons"],
)
def test_block_payload_names_the_operator_recovery_path(self):
report = stable_report(checkout_branch=None)
payload = scr.runtime_report_payload(report)
self.assertEqual(payload["kind"], "runtime_mode_block")
self.assertEqual(payload["blocker_kind"], scr.BLOCKER_UNKNOWN_RUNTIME)
self.assertTrue(
any("promotion-runbook" in line for line in payload["recovery"]))
class TestPostFlapReproving(unittest.TestCase):
"""Acceptance criterion 8: per-namespace post-flap re-proving."""
def test_no_flap_means_no_reproof_required(self):
state = scr.new_reproof_state()
res = scr.assess_namespace_reproof(state, "reviewer")
self.assertFalse(res["reproof_required"])
self.assertTrue(res["proven"])
def test_transport_recovery_requires_namespace_specific_reproving(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
res = scr.assess_namespace_reproof(state, "reviewer")
self.assertTrue(res["reproof_required"])
self.assertFalse(res["proven"])
self.assertEqual(
res["missing_steps"], list(scr.REQUIRED_NAMESPACE_PROOF_STEPS))
def test_author_proof_does_not_imply_other_namespaces(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
state = scr.record_namespace_proof(
state,
"author",
at="2026-07-20T14:05:00Z",
whoami=True,
runtime_context=True,
capability_resolved=True,
)
self.assertTrue(scr.assess_namespace_reproof(state, "author")["proven"])
for other in ("reviewer", "merger", "reconciler"):
assessment = scr.assess_namespace_reproof(state, other)
self.assertFalse(assessment["proven"], other)
self.assertTrue(
any("does not transfer" in reason
for reason in assessment["reasons"]),
other,
)
self.assertEqual(
scr.unproven_namespaces(state),
["reviewer", "merger", "reconciler"],
)
def test_proof_recorded_before_the_flap_does_not_count(self):
state = scr.record_namespace_proof(
scr.new_reproof_state(),
"merger",
at="2026-07-20T13:00:00Z",
whoami=True,
runtime_context=True,
capability_resolved=True,
)
state = scr.record_transport_flap(state, at="2026-07-20T14:00:00Z")
res = scr.assess_namespace_reproof(state, "merger")
self.assertFalse(res["proven"])
self.assertTrue(any("predates" in reason for reason in res["reasons"]))
def test_incomplete_proof_lists_the_missing_steps(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
state = scr.record_namespace_proof(
state, "reviewer", at="2026-07-20T14:05:00Z", whoami=True)
res = scr.assess_namespace_reproof(state, "reviewer")
self.assertFalse(res["proven"])
self.assertEqual(
res["missing_steps"], ["runtime_context", "capability_resolved"])
def test_stale_runtime_report_keeps_the_namespace_unproven(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
state = scr.record_namespace_proof(
state,
"reviewer",
at="2026-07-20T14:05:00Z",
whoami=True,
runtime_context=True,
capability_resolved=True,
stale_runtime_reported=True,
)
res = scr.assess_namespace_reproof(state, "reviewer")
self.assertFalse(res["proven"])
self.assertTrue(
any("stale-runtime" in reason for reason in res["reasons"]))
def test_unproven_namespace_blocks_the_mutation_gate(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
gate = scr.assess_runtime_mutation_gate(
stable_report(), namespace="reviewer", namespace_reproof=state)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_NAMESPACE_NOT_REPROVEN, gate["blocker_kinds"])
def test_reproven_namespace_clears_the_mutation_gate(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
state = scr.record_namespace_proof(
state,
"reviewer",
at="2026-07-20T14:05:00Z",
whoami=True,
runtime_context=True,
capability_resolved=True,
)
gate = scr.assess_runtime_mutation_gate(
stable_report(), namespace="reviewer", namespace_reproof=state)
self.assertFalse(gate["block"])
class TestPromotionRecord(unittest.TestCase):
"""Acceptance criteria 4 / 10: promotion records previous and promoted SHAs."""
def complete_record(self, **overrides):
record = {
"previous_runtime_sha": SHA_A,
"promoted_runtime_sha": SHA_B,
"source_branch": "feat/issue-615-runtime-mode-enforcement",
"source_pr": "770",
"restart_method": "operator reload of the stable control runtime",
"health_check_proof": "gitea_assess_mcp_namespace_health: healthy",
"identity_proof": "gitea_whoami: sysadmin / prgs-reviewer",
"profile_proof": "runtime context: prgs-reviewer",
"workspace_proof": "process root == canonical root, clean",
"mutation_capability_proof": "resolve review_pr: allowed",
"rollback_instructions": "re-promote " + SHA_A,
}
record.update(overrides)
return record
def test_complete_record_is_valid(self):
res = scr.assess_promotion_record(self.complete_record())
self.assertTrue(res["valid"])
self.assertEqual(res["missing_fields"], [])
def test_promotion_records_previous_and_promoted_shas(self):
res = scr.assess_promotion_record(
self.complete_record(previous_runtime_sha="", promoted_runtime_sha=""))
self.assertFalse(res["valid"])
self.assertIn("previous_runtime_sha", res["missing_fields"])
self.assertIn("promoted_runtime_sha", res["missing_fields"])
def test_identical_shas_are_not_a_promotion(self):
res = scr.assess_promotion_record(
self.complete_record(promoted_runtime_sha=SHA_A))
self.assertFalse(res["valid"])
self.assertTrue(
any("nothing was promoted" in reason for reason in res["reasons"]))
def test_missing_rollback_instructions_fail_closed(self):
res = scr.assess_promotion_record(
self.complete_record(rollback_instructions=""))
self.assertFalse(res["valid"])
self.assertIn("rollback_instructions", res["missing_fields"])
def test_empty_record_is_invalid(self):
self.assertFalse(scr.assess_promotion_record(None)["valid"])
class TestNormalSessionsCannotRestartStableRuntime(unittest.TestCase):
"""Acceptance criterion 3: normal sessions do not restart the stable MCP."""
def test_adr_forbids_kill_restart_and_relaunch(self):
adr = (
REPO_ROOT
/ "docs"
/ "architecture"
/ "mcp-stable-control-runtime-policy-adr.md"
).read_text()
for phrase in ("Kill the running MCP server process",
"Restart / relaunch the MCP server process",
"Relaunch MCP from a development worktree"):
self.assertIn(phrase, adr, phrase)
def test_promotion_runbook_exists_and_lists_every_record_field(self):
runbook = (
REPO_ROOT / "docs" / "stable-runtime-promotion-runbook.md"
).read_text()
for field in scr.PROMOTION_REQUIRED_FIELDS:
self.assertIn(field, runbook, field)
def test_no_mcp_tool_offers_a_runtime_restart(self):
server = (REPO_ROOT / "gitea_mcp_server.py").read_text()
for forbidden in ("def gitea_restart_", "def gitea_kill_"):
self.assertNotIn(forbidden, server, forbidden)
class TestServerWiring(unittest.TestCase):
"""The gate is wired into the server's mutation permission path."""
def setUp(self):
import gitea_mcp_server as srv # imported lazily: heavy module
self.srv = srv
def test_reads_are_never_blocked_by_runtime_mode(self):
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}):
self.assertEqual(self.srv._runtime_mode_block("gitea.read"), [])
def test_gate_is_skipped_under_pure_unit_test_isolation(self):
# The suite itself runs from a branches/ worktree (dev-test by design);
# without forced production guards the gate must not fire.
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
def test_dev_worktree_runtime_blocks_mutations_when_guards_forced(self):
report = stable_report(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
)
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
patch.object(
self.srv, "_current_runtime_mode_report", return_value=report):
reasons = self.srv._runtime_mode_block("gitea.pr.create")
self.assertTrue(reasons)
self.assertTrue(any("dev-test" in reason for reason in reasons))
def test_stable_runtime_allows_mutations_when_guards_forced(self):
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
patch.object(
self.srv,
"_current_runtime_mode_report",
return_value=stable_report()):
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
def test_unassessable_runtime_fails_closed(self):
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
patch.object(
self.srv,
"_current_runtime_mode_report",
side_effect=RuntimeError("boom")):
reasons = self.srv._runtime_mode_block("gitea.pr.create")
self.assertTrue(reasons)
self.assertTrue(any("fail closed" in reason for reason in reasons))
def test_live_report_describes_this_checkout(self):
report = self.srv._current_runtime_mode_report()
self.assertIn(report["runtime_mode"], scr.VALID_RUNTIME_MODES)
self.assertEqual(report["mcp_process_root"], self.srv.PROJECT_ROOT)
class TestServerWiringRealDerivation(unittest.TestCase):
"""Drive the *real* report derivation, not a pre-built fixture (#615 F3).
Every other server-wiring test patches ``_current_runtime_mode_report`` with
a fixture, so the derivation the daemon actually runs was never executed by
the suite. These tests patch only its *inputs* -- the import-time facts, the
dirty-file read, and the resolved namespace binding -- and let the real
function build the report.
"""
TASK_WORKTREE = STABLE_ROOT + "/branches/issue-615-runtime-mode-enforcement"
def setUp(self):
import gitea_mcp_server as srv # imported lazily: heavy module
self.srv = srv
def _stable_facts(self):
"""Immutable facts of a promoted stable-control runtime."""
return {
"checkout_branch": "master",
"runtime_head": SHA_A,
"is_git_checkout": True,
"dirty_files": [],
}
def _binding(self, *, roots_aligned=True, workspace=None):
"""A resolved namespace binding, as the server's resolver returns it."""
return {
"workspace_path": workspace or self.TASK_WORKTREE,
"canonical_repo_root": STABLE_ROOT,
"process_project_root": STABLE_ROOT,
"roots_aligned": roots_aligned,
}
def _real_derivation(self, *, dirty=None, roots_aligned=True, workspace=None):
"""Context managers that patch only the inputs, never the derivation."""
return (
patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}),
patch.object(self.srv, "PROJECT_ROOT", STABLE_ROOT),
patch.object(self.srv, "_STARTUP_RUNTIME_FACTS", self._stable_facts()),
patch.object(
self.srv,
"_resolve_namespace_mutation_context",
return_value=self._binding(
roots_aligned=roots_aligned, workspace=workspace),
),
patch.object(scr, "observe_dirty_files", return_value=list(dirty or [])),
)
def test_clean_stable_checkout_with_bound_task_worktree_permits_mutation(self):
# The sanctioned configuration: a clean control checkout on master plus a
# correctly bound branches/ worktree. Before the F1 fix this failed, because
# alignment was path equality between the task workspace and the process
# root, which a branches/ worktree can never satisfy.
env, root, facts, ctx, dirty = self._real_derivation()
with env, root, facts, ctx, dirty:
report = self.srv._current_runtime_mode_report()
self.assertEqual(report["runtime_mode"], scr.RUNTIME_MODE_STABLE)
self.assertEqual(report["active_task_workspace"], self.TASK_WORKTREE)
self.assertTrue(report["workspace_roots_aligned"])
self.assertTrue(
report["real_mutations_allowed"], report["mutation_block_reasons"])
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
def test_misaligned_process_and_canonical_roots_fail_closed(self):
# Alignment keeps its repository-level meaning: the namespace targeting a
# different repository than the process is installed in is the unsafe case.
env, root, facts, ctx, dirty = self._real_derivation(roots_aligned=False)
with env, root, facts, ctx, dirty:
report = self.srv._current_runtime_mode_report()
self.assertFalse(report["workspace_roots_aligned"])
self.assertFalse(report["real_mutations_allowed"])
reasons = self.srv._runtime_mode_block("gitea.pr.create")
self.assertTrue(reasons)
self.assertTrue(any("alignment" in reason for reason in reasons))
def test_newly_dirty_task_state_is_detected_after_an_earlier_clean_read(self):
# A clean read must not license every later mutation: the acceptance
# criterion 7 dirty blocker has to keep applying for the process lifetime.
env, root, facts, ctx, dirty = self._real_derivation(dirty=[])
with env, root, facts, ctx, dirty:
self.assertTrue(
self.srv._current_runtime_mode_report()["real_mutations_allowed"])
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
env, root, facts, ctx, dirty = self._real_derivation(
dirty=["gitea_mcp_server.py"])
with env, root, facts, ctx, dirty:
report = self.srv._current_runtime_mode_report()
self.assertEqual(report["dirty_files"], ["gitea_mcp_server.py"])
self.assertFalse(report["real_mutations_allowed"])
self.assertTrue(self.srv._runtime_mode_block("gitea.pr.create"))
def test_read_only_refresh_cannot_freeze_a_permissive_mutation_result(self):
# gitea_get_runtime_context() calls with refresh=True. That read-only call
# must not seed a cache that a later mutation gate would then trust.
env, root, facts, ctx, dirty = self._real_derivation(dirty=[])
with env, root, facts, ctx, dirty:
self.assertTrue(
self.srv._current_runtime_mode_report(refresh=True)[
"real_mutations_allowed"]
)
env, root, facts, ctx, dirty = self._real_derivation(
dirty=["stable_control_runtime.py"])
with env, root, facts, ctx, dirty:
self.assertFalse(
self.srv._current_runtime_mode_report()["real_mutations_allowed"])
self.assertTrue(self.srv._runtime_mode_block("gitea.pr.create"))
def test_unresolvable_binding_reports_unknown_alignment_never_alignment_proof(self):
# An unresolvable binding must report alignment as unknown (None), never
# as True. Only *definite* misalignment blocks: a session with no task
# binding resolved is the ordinary case, and failing it closed would
# reintroduce exactly the F1 breakage this change removes.
env, root, facts, _, dirty = self._real_derivation()
broken = patch.object(
self.srv,
"_resolve_namespace_mutation_context",
side_effect=RuntimeError("no binding"),
)
with env, root, facts, broken, dirty:
report = self.srv._current_runtime_mode_report()
self.assertIsNone(report["workspace_roots_aligned"])
self.assertNotIn(
scr.BLOCKER_UNSAFE_ALIGNMENT,
scr.assess_runtime_mutation_gate(report)["blocker_kinds"],
)
if __name__ == "__main__":
unittest.main()
+67 -1
View File
@@ -19,7 +19,12 @@ import unittest
import gitea_config
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
from task_capability_map import required_permission, required_role
from task_capability_map import (
ROLE_EXCLUSIVE_TASKS,
TASK_CAPABILITY_MAP,
required_permission,
required_role,
)
# Canonical role-profile permission shape. Mirrors the configured
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
@@ -112,6 +117,42 @@ FORMAL_REVIEW_TASKS = (
"pr-queue-cleanup",
)
# Complete resolver role-exclusive set on master when #723 was reconstructed.
# The shared constant must replace this exact inline authority without dropping
# later lease and PR-sync aliases added after the preserved source commits.
EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
{
"acquire_reviewer_pr_lease",
"gitea_acquire_reviewer_pr_lease",
"review_pr",
"approve_pr",
"request_changes_pr",
"blind_pr_queue_review",
"pr_queue_cleanup",
"pr-queue-cleanup",
"merge_pr",
"acquire_merger_pr_lease",
"gitea_acquire_merger_pr_lease",
"adopt_merger_pr_lease",
"gitea_adopt_merger_pr_lease",
"release_merger_pr_lease",
"gitea_release_merger_pr_lease",
"create_branch",
"push_branch",
"create_pr",
"commit_files",
"gitea_commit_files",
"address_pr_change_requests",
"update_pr_branch_by_merge",
"gitea_update_pr_branch_by_merge",
"delete_branch",
"cleanup_merged_pr_branch",
"reconciliation_cleanup",
"work_issue",
"work-issue",
}
)
def _profile_satisfies(role_name, task):
"""True when the canonical *role_name* profile can perform *task*."""
@@ -201,5 +242,30 @@ class TestMergerBoundary(unittest.TestCase):
)
class TestRoleExclusiveSetIntegrity(unittest.TestCase):
"""#723: the shared set is complete, mapped, and role-satisfiable."""
def test_complete_current_role_exclusive_set(self):
self.assertEqual(ROLE_EXCLUSIVE_TASKS, EXPECTED_ROLE_EXCLUSIVE_TASKS)
def test_every_role_exclusive_task_exists_in_capability_map(self):
for task in sorted(ROLE_EXCLUSIVE_TASKS):
with self.subTest(task=task):
self.assertIn(task, TASK_CAPABILITY_MAP)
def test_formal_review_tasks_are_role_exclusive(self):
self.assertTrue(set(FORMAL_REVIEW_TASKS) <= ROLE_EXCLUSIVE_TASKS)
def test_every_role_exclusive_task_has_a_satisfying_profile(self):
for task in sorted(ROLE_EXCLUSIVE_TASKS):
with self.subTest(task=task):
role = required_role(task)
self.assertIn(role, CANONICAL_ROLE_PROFILES)
self.assertTrue(
_profile_satisfies(role, task),
f"canonical {role!r} profile cannot satisfy {task!r}",
)
if __name__ == "__main__":
unittest.main()
+555
View File
@@ -0,0 +1,555 @@
"""#780: ``status:pr-open`` must not survive a terminal PR transition.
The leak this file locks down: ``gitea_create_pr`` applied ``status:pr-open``
and no terminal path ever removed it, so a repository audit found 40 closed
issues still advertising an open PR that had long since merged or closed.
Coverage mirrors the issue's acceptance criteria: merge, close-without-merge,
supersession, already-landed reconciliation, controller closure, retry /
idempotency, unrelated-label preservation, the only-label (empty set) case,
and terminal validation of any residual label.
"""
from __future__ import annotations
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import terminal_pr_label_cleanup as tplc
FAKE_AUTH = "token test-token"
PR_OPEN = tplc.PR_OPEN_LABEL
def _lb(name: str, lid: int) -> dict:
return {"id": lid, "name": name, "color": "000000"}
# ---------------------------------------------------------------------------
# Pure rule: planning
# ---------------------------------------------------------------------------
class TestPlanPrOpenCleanup(unittest.TestCase):
def test_removes_only_the_pr_open_label(self):
plan = tplc.plan_pr_open_cleanup(
["type:bug", PR_OPEN, "workflow-hardening"],
terminal_reason=tplc.MERGED,
)
self.assertTrue(plan["cleanup_required"])
self.assertEqual(plan["removed"], [PR_OPEN])
self.assertEqual(plan["labels_after"], ["type:bug", "workflow-hardening"])
def test_preserves_unrelated_labels_in_original_order(self):
labels = ["workflow-hardening", "type:bug", PR_OPEN, "role:author", "leases"]
plan = tplc.plan_pr_open_cleanup(labels, terminal_reason=tplc.MERGED)
self.assertEqual(
plan["labels_after"],
["workflow-hardening", "type:bug", "role:author", "leases"],
)
self.assertNotIn(PR_OPEN, plan["labels_after"])
def test_only_label_yields_empty_set(self):
plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=tplc.MERGED)
self.assertTrue(plan["cleanup_required"])
self.assertEqual(plan["labels_after"], [])
self.assertTrue(plan["empty_label_set"])
def test_absent_label_is_an_idempotent_noop(self):
plan = tplc.plan_pr_open_cleanup(
["type:bug", "status:done"], terminal_reason=tplc.RETRY_RECOVERY
)
self.assertFalse(plan["cleanup_required"])
self.assertTrue(plan["idempotent_noop"])
self.assertEqual(plan["labels_after"], ["type:bug", "status:done"])
def test_accepts_gitea_label_objects(self):
plan = tplc.plan_pr_open_cleanup(
{"labels": [{"name": PR_OPEN}, {"name": "type:bug"}]},
terminal_reason=tplc.SUPERSEDED,
)
self.assertEqual(plan["labels_after"], ["type:bug"])
def test_every_terminal_reason_is_planable(self):
for reason in tplc.TERMINAL_REASONS:
plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=reason)
self.assertEqual(plan["terminal_reason"], reason)
self.assertTrue(plan["terminal_reason_description"])
def test_unknown_terminal_reason_fails_closed(self):
with self.assertRaises(ValueError):
tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason="whenever")
def test_reason_aliases_normalize(self):
self.assertEqual(tplc.canonical_terminal_reason("merge"), tplc.MERGED)
self.assertEqual(
tplc.canonical_terminal_reason("already-landed"), tplc.ALREADY_LANDED
)
self.assertEqual(
tplc.canonical_terminal_reason("controller-closure"),
tplc.CONTROLLER_CLOSURE,
)
# ---------------------------------------------------------------------------
# Pure rule: read-after-write verification
# ---------------------------------------------------------------------------
class TestVerifyPrOpenCleanup(unittest.TestCase):
def test_verified_when_observed_matches_plan(self):
plan = tplc.plan_pr_open_cleanup(
["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
)
result = tplc.verify_pr_open_cleanup(["type:bug"], plan=plan)
self.assertTrue(result["verified"])
self.assertFalse(result["residual"])
self.assertEqual(result["reasons"], [])
def test_residual_label_is_reported(self):
plan = tplc.plan_pr_open_cleanup(
["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
)
result = tplc.verify_pr_open_cleanup(["type:bug", PR_OPEN], plan=plan)
self.assertFalse(result["verified"])
self.assertTrue(result["residual"])
self.assertIn(PR_OPEN, result["reasons"][0])
self.assertTrue(result["safe_next_action"])
def test_dropped_unrelated_label_is_reported(self):
plan = tplc.plan_pr_open_cleanup(
["type:bug", "leases", PR_OPEN], terminal_reason=tplc.MERGED
)
result = tplc.verify_pr_open_cleanup(["type:bug"], plan=plan)
self.assertFalse(result["verified"])
self.assertEqual(result["unexpected_removals"], ["leases"])
def test_unexpected_added_label_is_reported(self):
plan = tplc.plan_pr_open_cleanup(
["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
)
result = tplc.verify_pr_open_cleanup(["type:bug", "surprise"], plan=plan)
self.assertFalse(result["verified"])
self.assertEqual(result["unexpected_additions"], ["surprise"])
def test_empty_observed_set_verifies_for_only_label_case(self):
plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=tplc.MERGED)
result = tplc.verify_pr_open_cleanup([], plan=plan)
self.assertTrue(result["verified"])
self.assertTrue(result["empty_label_set"])
# ---------------------------------------------------------------------------
# Terminal validation
# ---------------------------------------------------------------------------
class TestDetectResidualPrOpen(unittest.TestCase):
def test_clean_repository(self):
issues = [
{"number": 1, "state": "closed", "labels": [{"name": "type:bug"}]},
{"number": 2, "state": "open", "labels": []},
]
result = tplc.detect_residual_pr_open(issues)
self.assertTrue(result["clean"])
self.assertEqual(result["residual_count"], 0)
self.assertEqual(result["checked_count"], 2)
def test_reports_each_stale_issue(self):
issues = [
{"number": 626, "state": "closed", "labels": [{"name": PR_OPEN}]},
{"number": 772, "state": "closed", "labels": [{"name": PR_OPEN}]},
{"number": 9, "state": "open", "labels": [{"name": "type:bug"}]},
]
result = tplc.detect_residual_pr_open(issues)
self.assertFalse(result["clean"])
self.assertEqual(result["residual_count"], 2)
self.assertEqual(
[entry["number"] for entry in result["residual_issues"]], [626, 772]
)
self.assertTrue(result["safe_next_action"])
def test_issue_with_a_live_open_pr_is_not_residual(self):
issues = [{"number": 42, "state": "open", "labels": [{"name": PR_OPEN}]}]
result = tplc.detect_residual_pr_open(issues, open_pr_issue_numbers=[42])
self.assertTrue(result["clean"])
self.assertEqual(result["exempt_open_pr_issues"], [42])
# ---------------------------------------------------------------------------
# Executor: one authoritative rule, with read-after-write proof
# ---------------------------------------------------------------------------
class _ExecutorHarness(unittest.TestCase):
"""Drives mcp_server.clear_pr_open_label against a fake Gitea."""
def setUp(self):
self.issue_labels: dict[int, list[str]] = {}
self.repo_labels = {
PR_OPEN: 4,
"type:bug": 1,
"workflow-hardening": 2,
"leases": 3,
"status:done": 5,
}
self.puts: list[tuple[int, list[int]]] = []
patch("mcp_server._resolve", return_value=("h", "o", "r")).start()
patch("mcp_server._auth", return_value=FAKE_AUTH).start()
patch(
"mcp_server.repo_api_url",
return_value="https://gitea.example/api/v1/repos/o/r",
).start()
patch("gitea_audit.audit_enabled", return_value=False).start()
patch("mcp_server.api_request", side_effect=self._api).start()
# api_get_all resolves api_request inside gitea_auth, so patching the
# mcp_server binding alone would let the label inventory hit the network.
patch("mcp_server.api_get_all", side_effect=self._api_get_all).start()
self.addCleanup(patch.stopall)
def _api_get_all(self, url, auth, **_kwargs):
if "/labels" in url:
return [_lb(name, lid) for name, lid in self.repo_labels.items()]
raise AssertionError(f"unexpected paginated GET: {url}")
def _api(self, method, url, auth, payload=None):
if method == "GET" and "/issues/" in url:
num = int(url.rsplit("/issues/", 1)[1].split("?")[0])
return {
"number": num,
"labels": [
{"name": n, "id": self.repo_labels[n]}
for n in self.issue_labels.get(num, [])
],
}
if method == "PUT" and url.endswith("/labels"):
num = int(url.rsplit("/issues/", 1)[1].split("/")[0])
ids = payload["labels"]
by_id = {lid: name for name, lid in self.repo_labels.items()}
names = [by_id[i] for i in ids]
self.puts.append((num, ids))
self.issue_labels[num] = names
return [_lb(n, self.repo_labels[n]) for n in names]
raise AssertionError(f"unexpected API call: {method} {url}")
def _clear(self, numbers, reason=tplc.MERGED):
return mcp_server.clear_pr_open_label(
numbers, "prgs", None, None, None, terminal_reason=reason
)
class TestClearPrOpenLabel(_ExecutorHarness):
def test_removes_label_and_preserves_the_rest(self):
self.issue_labels[780] = ["type:bug", PR_OPEN, "workflow-hardening"]
summary = self._clear([780])
self.assertTrue(summary["clean"])
self.assertEqual(summary["removed"], [780])
self.assertEqual(
self.issue_labels[780], ["type:bug", "workflow-hardening"]
)
def test_only_label_results_in_empty_set(self):
self.issue_labels[626] = [PR_OPEN]
summary = self._clear([626])
self.assertTrue(summary["clean"])
self.assertEqual(self.issue_labels[626], [])
self.assertEqual(self.puts, [(626, [])])
self.assertTrue(summary["results"][0]["empty_label_set"])
def test_read_after_write_proof_is_returned(self):
self.issue_labels[780] = ["type:bug", PR_OPEN]
summary = self._clear([780])
entry = summary["results"][0]
self.assertTrue(entry["verified"])
self.assertEqual(entry["labels_before"], ["type:bug", PR_OPEN])
self.assertEqual(entry["labels_after"], ["type:bug"])
self.assertEqual(entry["verification"]["observed_labels"], ["type:bug"])
def test_repeated_cleanup_is_harmless(self):
self.issue_labels[780] = ["type:bug", PR_OPEN]
first = self._clear([780])
second = self._clear([780], reason=tplc.RETRY_RECOVERY)
third = self._clear([780], reason=tplc.RETRY_RECOVERY)
self.assertTrue(first["clean"] and second["clean"] and third["clean"])
self.assertEqual(second["already_absent"], [780])
self.assertEqual(third["already_absent"], [780])
# Exactly one mutation across three calls.
self.assertEqual(len(self.puts), 1)
self.assertEqual(self.issue_labels[780], ["type:bug"])
def test_noop_path_never_reads_the_label_inventory(self):
self.issue_labels[780] = ["type:bug"]
with patch("mcp_server._repo_label_id_map") as mock_map:
summary = self._clear([780])
self.assertTrue(summary["clean"])
mock_map.assert_not_called()
def test_duplicate_issue_numbers_are_collapsed(self):
self.issue_labels[780] = ["type:bug", PR_OPEN]
summary = self._clear([780, 780, "780"])
self.assertEqual(summary["checked"], [780])
self.assertEqual(len(self.puts), 1)
def test_no_issue_numbers_is_a_clean_noop(self):
summary = self._clear([])
self.assertTrue(summary["clean"])
self.assertEqual(summary["checked"], [])
def test_failed_mutation_is_reported_not_swallowed(self):
self.issue_labels[780] = ["type:bug", PR_OPEN]
def boom(*_a, **_kw):
raise RuntimeError("gitea exploded")
with patch("mcp_server._put_issue_label_names", side_effect=boom):
summary = self._clear([780])
self.assertFalse(summary["clean"])
self.assertEqual(summary["failed"], [780])
self.assertTrue(summary["safe_next_action"])
self.assertIn(PR_OPEN, self.issue_labels[780])
def test_residual_label_after_write_fails_verification(self):
self.issue_labels[780] = ["type:bug", PR_OPEN]
real_api = self._api
# Simulate a write that reports success but leaves the label behind.
def sticky(method, url, auth, payload=None):
if method == "PUT" and url.endswith("/labels"):
num = int(url.rsplit("/issues/", 1)[1].split("/")[0])
self.puts.append((num, payload["labels"]))
return [_lb("type:bug", 1), _lb(PR_OPEN, 4)]
return real_api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=sticky):
summary = self._clear([780])
self.assertFalse(summary["clean"])
self.assertEqual(summary["failed"], [780])
# ---------------------------------------------------------------------------
# Terminal workflow paths
# ---------------------------------------------------------------------------
class TestTerminalPathsUseTheSharedRule(_ExecutorHarness):
"""Merge, close-without-merge, supersession and already-landed."""
def test_merge_path_clears_the_label_for_linked_issues(self):
self.issue_labels[780] = ["type:bug", PR_OPEN]
merged_pr = {
"title": "fix: terminal label cleanup",
"body": "Closes #780",
"head": {"ref": "fix/issue-780-terminal-pr-open-label-cleanup"},
}
with patch(
"mcp_server.release_in_progress_label", return_value={780: "released"}
):
result = mcp_server.cleanup_in_progress_for_pr(
merged_pr, "prgs", None, None, None, terminal_reason=tplc.MERGED
)
cleanup = result["pr_open_label_cleanup"]
self.assertTrue(cleanup["clean"])
self.assertEqual(cleanup["terminal_reason"], tplc.MERGED)
self.assertEqual(self.issue_labels[780], ["type:bug"])
def test_close_without_merge_clears_the_label(self):
self.issue_labels[781] = ["type:bug", PR_OPEN, "leases"]
closed_pr = {
"title": "chore: abandoned",
"body": "Closes #781",
"head": {"ref": "chore/issue-781-abandoned"},
}
with patch(
"mcp_server.release_in_progress_label", return_value={781: "released"}
):
result = mcp_server.cleanup_in_progress_for_pr(
closed_pr,
"prgs",
None,
None,
None,
terminal_reason=tplc.CLOSED_WITHOUT_MERGE,
)
cleanup = result["pr_open_label_cleanup"]
self.assertTrue(cleanup["clean"])
self.assertEqual(cleanup["terminal_reason"], tplc.CLOSED_WITHOUT_MERGE)
self.assertEqual(self.issue_labels[781], ["type:bug", "leases"])
def test_pr_without_linked_issue_reports_an_empty_cleanup(self):
pr = {"title": "chore: no link", "body": "", "head": {"ref": "chore/none"}}
result = mcp_server.cleanup_in_progress_for_pr(
pr, "prgs", None, None, None, terminal_reason=tplc.MERGED
)
self.assertEqual(result["cleanup_status"], "no linked issue found")
self.assertTrue(result["pr_open_label_cleanup"]["clean"])
self.assertEqual(result["pr_open_label_cleanup"]["checked"], [])
def test_supersession_reason_is_recorded(self):
self.issue_labels[600] = [PR_OPEN, "type:bug"]
summary = self._clear([600], reason=tplc.SUPERSEDED)
self.assertTrue(summary["clean"])
self.assertEqual(summary["terminal_reason"], tplc.SUPERSEDED)
self.assertEqual(self.issue_labels[600], ["type:bug"])
def test_already_landed_reconciliation_reason_is_recorded(self):
self.issue_labels[601] = [PR_OPEN]
summary = self._clear([601], reason=tplc.ALREADY_LANDED)
self.assertTrue(summary["clean"])
self.assertEqual(summary["terminal_reason"], tplc.ALREADY_LANDED)
self.assertEqual(self.issue_labels[601], [])
def test_issue_780_regression_stale_label_survived_every_terminal_path(self):
"""Regression for the observed leak.
Before the fix each terminal path finished without touching
``status:pr-open``, so the audit found closed issues still carrying it.
Every path now routes through the one shared rule and leaves nothing
behind while preserving each issue's other labels.
"""
stale = {
626: (["type:bug", PR_OPEN], tplc.CONTROLLER_CLOSURE),
772: (["workflow-hardening", PR_OPEN], tplc.MERGED),
768: ([PR_OPEN], tplc.CLOSED_WITHOUT_MERGE),
758: (["leases", PR_OPEN, "type:bug"], tplc.SUPERSEDED),
755: (["status:done", PR_OPEN], tplc.ALREADY_LANDED),
}
for number, (labels, _reason) in stale.items():
self.issue_labels[number] = list(labels)
for number, (_labels, reason) in stale.items():
summary = self._clear([number], reason=reason)
self.assertTrue(summary["clean"], msg=f"issue #{number}")
audit = tplc.detect_residual_pr_open(
[
{"number": num, "state": "closed", "labels": names}
for num, names in self.issue_labels.items()
]
)
self.assertTrue(audit["clean"])
self.assertEqual(audit["residual_count"], 0)
# Unrelated labels survived every path.
self.assertEqual(self.issue_labels[626], ["type:bug"])
self.assertEqual(self.issue_labels[772], ["workflow-hardening"])
self.assertEqual(self.issue_labels[768], [])
self.assertEqual(self.issue_labels[758], ["leases", "type:bug"])
self.assertEqual(self.issue_labels[755], ["status:done"])
# ---------------------------------------------------------------------------
# Controller closure
# ---------------------------------------------------------------------------
class TestControllerClosure(unittest.TestCase):
def test_close_issue_clears_label_before_closing_and_validates(self):
calls: list[str] = []
def fake_clear(numbers, *_a, **kwargs):
calls.append(f"clear:{kwargs['terminal_reason']}")
return {
"label": PR_OPEN,
"clean": True,
"checked": list(numbers),
"removed": list(numbers),
"already_absent": [],
"failed": [],
"results": [],
"reasons": [],
"safe_next_action": "",
"terminal_reason": kwargs["terminal_reason"],
}
def fake_api(method, url, auth, payload=None):
if method == "PATCH":
calls.append("patch:closed")
return {"state": "closed"}
return {"labels": [{"name": "type:bug"}]}
with patch("mcp_server.clear_pr_open_label", side_effect=fake_clear), \
patch("mcp_server.api_request", side_effect=fake_api), \
patch("mcp_server._profile_permission_block", return_value=None), \
patch("mcp_server.verify_preflight_purity", return_value=None), \
patch("mcp_server.release_in_progress_label", return_value={}), \
patch("mcp_server._resolve", return_value=("h", "o", "r")), \
patch("mcp_server._auth", return_value=FAKE_AUTH), \
patch("gitea_audit.audit_enabled", return_value=False):
result = mcp_server.gitea_close_issue(issue_number=780, remote="prgs")
self.assertTrue(result["success"])
# Cleanup precedes the state change: closing first would bake in the leak.
self.assertEqual(calls[0], f"clear:{tplc.CONTROLLER_CLOSURE}")
self.assertIn("patch:closed", calls)
self.assertTrue(result["terminal_label_validation"]["clean"])
def test_close_issue_fails_closed_when_cleanup_cannot_complete(self):
def fake_clear(numbers, *_a, **kwargs):
return {
"label": PR_OPEN,
"clean": False,
"checked": list(numbers),
"removed": [],
"already_absent": [],
"failed": list(numbers),
"results": [],
"reasons": ["label replacement failed: boom"],
"safe_next_action": "retry",
"terminal_reason": kwargs["terminal_reason"],
}
def fail_on_patch(method, url, auth, payload=None):
if method == "PATCH":
raise AssertionError("issue must not be closed when cleanup failed")
return {}
with patch("mcp_server.clear_pr_open_label", side_effect=fake_clear), \
patch("mcp_server.api_request", side_effect=fail_on_patch), \
patch("mcp_server._profile_permission_block", return_value=None), \
patch("mcp_server.verify_preflight_purity", return_value=None), \
patch("mcp_server._resolve", return_value=("h", "o", "r")), \
patch("mcp_server._auth", return_value=FAKE_AUTH), \
patch("gitea_audit.audit_enabled", return_value=False):
result = mcp_server.gitea_close_issue(issue_number=780, remote="prgs")
self.assertFalse(result["success"])
self.assertTrue(result["blocked"])
self.assertFalse(result["performed"])
self.assertIn("#780", result["message"])
self.assertTrue(result["safe_next_action"])
# ---------------------------------------------------------------------------
# Capability wiring
# ---------------------------------------------------------------------------
class TestCapabilityWiring(unittest.TestCase):
def test_task_is_registered_with_label_authority(self):
import task_capability_map
self.assertEqual(
task_capability_map.required_permission("cleanup_terminal_pr_labels"),
"gitea.issue.comment",
)
self.assertEqual(
task_capability_map.required_role("cleanup_terminal_pr_labels"),
"author",
)
self.assertEqual(
task_capability_map.tool_required_permission(
"gitea_cleanup_terminal_pr_labels"
),
"gitea.issue.comment",
)
def test_recovery_tool_rejects_an_unknown_reason_without_mutating(self):
with patch("mcp_server._profile_permission_block", return_value=None), \
patch("mcp_server.verify_preflight_purity", return_value=None), \
patch("mcp_server.clear_pr_open_label") as mock_clear:
result = mcp_server.gitea_cleanup_terminal_pr_labels(
issue_numbers=[780], terminal_reason="sometime", remote="prgs"
)
self.assertFalse(result["success"])
self.assertFalse(result["clean"])
mock_clear.assert_not_called()
if __name__ == "__main__":
unittest.main()
+274
View File
@@ -0,0 +1,274 @@
"""Hermetic tests for workflow dashboard (#605).
Covers terminal-blocked queue shapes in the spirit of #593/#592/#587 where an
active terminal-review lock must suppress other PRs as safe review/merge work.
"""
from __future__ import annotations
import unittest
from allocator_service import WorkCandidate
from workflow_dashboard import (
DASHBOARD_VERSION,
build_workflow_dashboard,
format_human_summary,
)
def _issue(
number: int,
*,
title: str = "",
labels: tuple[str, ...] = ("status:ready",),
priority: int = 20,
blocked: bool = False,
dependency_unmet: bool = False,
dependency_reason: str | None = None,
claimed: bool = False,
) -> WorkCandidate:
return WorkCandidate(
kind="issue",
number=number,
title=title or f"issue {number}",
labels=labels,
priority=priority,
blocked=blocked,
dependency_unmet=dependency_unmet,
dependency_reason=dependency_reason,
already_claimed_elsewhere=claimed,
)
def _pr(
number: int,
*,
title: str = "",
head_sha: str = "abc123",
request_changes: bool = False,
approved: bool = False,
mergeable: bool = False,
contaminated: bool = False,
approval_stale: bool = False,
priority: int = 5,
) -> WorkCandidate:
return WorkCandidate(
kind="pr",
number=number,
title=title or f"pr {number}",
head_sha=head_sha,
request_changes_current_head=request_changes,
approval_on_current_head=approved,
mergeable=mergeable,
approval_contaminated=contaminated,
approval_stale=approval_stale,
priority=priority,
)
class TestWorkflowDashboard(unittest.TestCase):
def test_version_and_read_only_payload(self):
snap = build_workflow_dashboard(
candidates=[_issue(605)],
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
payload = snap.as_dict()
self.assertTrue(payload["read_only"])
self.assertEqual(payload["dashboard_version"], DASHBOARD_VERSION)
self.assertTrue(payload["success"])
self.assertTrue(payload["inventory_complete"])
self.assertIn("human_summary", payload)
def test_never_marks_blocked_as_safe(self):
candidates = [
_issue(10, blocked=True, labels=("status:blocked",)),
_issue(11, dependency_unmet=True, dependency_reason="depends on #9"),
_issue(12, claimed=True),
_issue(605, labels=("status:ready",)),
]
snap = build_workflow_dashboard(candidates=candidates)
blocked_numbers = {e.number for e in snap.blocked_items}
self.assertIn(10, blocked_numbers)
self.assertIn(11, blocked_numbers)
self.assertIn(12, blocked_numbers)
for entry in snap.blocked_items:
self.assertFalse(entry.as_dict()["is_safe"])
self.assertEqual(entry.safe_for_roles, ())
self.assertIsNotNone(entry.block_reason)
author = snap.next_safe_by_role["author"]
self.assertEqual(author.status, "safe")
self.assertEqual(author.target_number, 605)
self.assertNotIn(author.target_number, blocked_numbers)
summary = format_human_summary(snap)
self.assertIn("NOT safe", summary)
self.assertIn("issue#10", summary.replace(" ", ""))
def test_author_prefers_oldest_ready_issue(self):
candidates = [
_issue(620, labels=("status:ready",)),
_issue(605, labels=("status:ready",)),
_issue(610, labels=("status:ready",)),
]
snap = build_workflow_dashboard(candidates=candidates)
author = snap.next_safe_by_role["author"]
self.assertEqual(author.status, "safe")
self.assertEqual(author.target_number, 605)
self.assertIn("gitea_allocate_next_work", author.prompt)
self.assertIn("role='author'", author.prompt)
def test_review_and_merge_ready_buckets(self):
candidates = [
_pr(100, head_sha="r1"), # review-ready
_pr(101, approved=True, mergeable=True, head_sha="m1", priority=8),
_pr(102, request_changes=True, head_sha="a1", priority=10),
]
snap = build_workflow_dashboard(candidates=candidates)
self.assertEqual([e.number for e in snap.review_ready_prs], [100])
self.assertEqual([e.number for e in snap.merge_ready_prs], [101])
self.assertEqual([e.number for e in snap.author_remediation], [102])
reviewer = snap.next_safe_by_role["reviewer"]
self.assertEqual(reviewer.status, "safe")
self.assertEqual(reviewer.target_number, 100)
self.assertEqual(reviewer.head_sha, "r1")
merger = snap.next_safe_by_role["merger"]
self.assertEqual(merger.status, "safe")
self.assertEqual(merger.target_number, 101)
self.assertEqual(merger.head_sha, "m1")
author = snap.next_safe_by_role["author"]
self.assertEqual(author.status, "safe")
self.assertEqual(author.target_number, 102)
def test_terminal_lock_blocks_other_prs_as_safe(
self,
):
"""#593/#592/#587-style: terminal lock ⇒ other PRs are not safe."""
candidates = [
_pr(587, head_sha="deadbeef", priority=5),
_pr(592, approved=True, mergeable=True, head_sha="cafebabe", priority=8),
_pr(593, head_sha="terminalhead", priority=9),
_issue(605, labels=("status:ready",)),
]
snap = build_workflow_dashboard(
candidates=candidates,
terminal_pr=593,
terminal_lock={"terminal_pr": 593, "active": True, "state": "locked"},
)
# Non-terminal PRs must appear blocked, never in safe buckets.
blocked_prs = {
e.number for e in snap.blocked_items if e.kind == "pr"
}
self.assertIn(587, blocked_prs)
self.assertIn(592, blocked_prs)
self.assertNotIn(593, blocked_prs) # terminal PR itself may still be routeable
# Terminal PR itself may remain review-ready; others must not.
self.assertEqual([e.number for e in snap.review_ready_prs], [593])
self.assertEqual(snap.merge_ready_prs, [])
self.assertNotIn(587, [e.number for e in snap.review_ready_prs])
self.assertNotIn(592, [e.number for e in snap.merge_ready_prs])
for entry in snap.blocked_items:
if entry.number in (587, 592):
self.assertIn("terminal-review lock", entry.block_reason or "")
self.assertEqual(entry.safe_for_roles, ())
self.assertFalse(entry.as_dict()["is_safe"])
reviewer = snap.next_safe_by_role["reviewer"]
# Reviewer may only target the terminal PR — never 587/592.
self.assertEqual(reviewer.status, "safe")
self.assertEqual(reviewer.target_number, 593)
self.assertEqual(reviewer.head_sha, "terminalhead")
self.assertNotEqual(reviewer.target_number, 587)
self.assertNotEqual(reviewer.target_number, 592)
merger = snap.next_safe_by_role["merger"]
# Merge-ready #592 is NOT safe while terminal lock is on #593.
self.assertNotEqual(merger.target_number, 592)
self.assertIn("593", merger.prompt)
self.assertIn(
merger.status,
("blocked_terminal", "idle", "safe"),
)
if merger.status == "safe":
self.assertEqual(merger.target_number, 593)
# Author issue work remains visible (issues are not terminal-blocked).
author = snap.next_safe_by_role["author"]
self.assertEqual(author.status, "safe")
self.assertEqual(author.target_number, 605)
summary = format_human_summary(snap)
self.assertIn("Terminal review lock: ACTIVE on PR #593", summary)
self.assertIn("Do not treat other open PRs as safe", summary)
def test_incomplete_inventory_fails_closed(self):
snap = build_workflow_dashboard(
candidates=[_issue(605)],
inventory_complete=False,
inventory_reasons=["page truncated"],
)
payload = snap.as_dict()
self.assertFalse(payload["inventory_complete"])
self.assertEqual(payload["review_ready_prs"], [])
self.assertEqual(payload["merge_ready_prs"], [])
for action in snap.next_safe_by_role.values():
self.assertEqual(action.status, "none")
self.assertIsNone(action.target_number)
self.assertIn("inventory incomplete", action.prompt.lower())
self.assertFalse(action.as_dict()["is_safe"])
def test_leases_partition_active_vs_stale(self):
leases = [
{"lease_id": "L1", "role": "author", "status": "active", "work_number": 605},
{"lease_id": "L2", "role": "reviewer", "status": "expired", "work_number": 99},
{"lease_id": "L3", "role": "merger", "stale": True, "work_number": 88},
]
snap = build_workflow_dashboard(candidates=[], leases=leases)
self.assertEqual(len(snap.active_leases_by_role["author"]), 1)
self.assertEqual(len(snap.stale_or_expired_leases), 2)
def test_discussion_and_controller_needed(self):
candidates = [
_issue(1, labels=("discussion", "type:discussion")),
_pr(2, contaminated=True, head_sha="x"),
]
snap = build_workflow_dashboard(candidates=candidates)
self.assertEqual([e.number for e in snap.discussion_issues], [1])
self.assertTrue(any(e.number == 2 for e in snap.controller_needed))
recon = snap.next_safe_by_role["reconciler"]
self.assertEqual(recon.status, "safe")
self.assertEqual(recon.target_number, 2)
def test_human_summary_includes_exact_prompts(self):
snap = build_workflow_dashboard(
candidates=[_issue(605)],
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
text = format_human_summary(snap)
self.assertIn("gitea_allocate_next_work", text)
self.assertIn("prgs/Scaled-Tech-Consulting/Gitea-Tools", text)
self.assertIn("never self-selects", text.lower())
self.assertIn("Primary next:", text)
def test_missing_pr_head_sha_is_blocked(self):
candidates = [_pr(50, head_sha="")]
# WorkCandidate allows empty head; dashboard must block it.
c = candidates[0]
c.head_sha = ""
snap = build_workflow_dashboard(candidates=[c])
self.assertEqual(len(snap.blocked_items), 1)
self.assertIn("head_sha", snap.blocked_items[0].block_reason or "")
self.assertEqual(snap.review_ready_prs, [])
if __name__ == "__main__":
unittest.main()
+230
View File
@@ -304,6 +304,193 @@ Who/what acts next:
)
)
# ── Stable control runtime states (#615) ─────────────────────────────────────
EXAMPLES.append(
_example(
"runtime_healthy",
"""
[CONTROLLER HANDOFF] Runtime check stable control runtime healthy
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- none
""",
f"""
[THREAD STATE LEDGER] Runtime stable control runtime healthy
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: runtime reported real_mutations_allowed=true
- Latest known validation: gitea_get_runtime_context read in this session
What changed:
- nothing; this is a read-only runtime observation
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: proceed with the allocated workflow phase
- Do not do: restart or relaunch the stable runtime
- Resume from: gitea_workflow_dashboard
""",
)
)
EXAMPLES.append(
_example(
"transport_flap_recovered",
"""
[CONTROLLER HANDOFF] Runtime check transport flap recovered
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- environment/tooling blocker: MCP transport dropped mid-session and recovered
""",
f"""
[THREAD STATE LEDGER] Runtime transport flap recovered, namespaces re-proven
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: all four namespaces re-proven after the flap
- Latest known validation: whoami + runtime context + capability resolve per namespace
What changed:
- author, reviewer, merger, and reconciler namespaces each re-proven independently
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: resume the interrupted workflow phase from its last durable state
- Do not do: treat author proof as proof of the other namespaces
- Resume from: the phase handoff that preceded the flap
""",
)
)
EXAMPLES.append(
_example(
"namespace_not_yet_reproven",
"""
[CONTROLLER HANDOFF] Runtime check reviewer namespace not re-proven
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- environment/tooling blocker: reviewer namespace not re-proven since the transport flap
""",
f"""
[THREAD STATE LEDGER] Runtime reviewer namespace not re-proven after flap
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: reviewer namespace unproven; mutation gate fails closed
- Latest known validation: author namespace re-proven; reviewer not attempted
What changed:
- reviewer mutations blocked with namespace_not_reproven_after_flap
What is blocked:
- Blocker classification: environment/tooling blocker
Who/what acts next:
- Next actor: reviewer
- Required action: run whoami, runtime context, and capability resolve in the reviewer namespace
- Do not do: substitute author proof for reviewer proof
- Resume from: docs/stable-runtime-promotion-runbook.md section 5
""",
)
)
EXAMPLES.append(
_example(
"promotion_completed",
"""
[CONTROLLER HANDOFF] Runtime promotion completed
Server-side mutation ledger:
- gitea_create_issue_comment on #615 with the promotion record
Blockers:
- none
""",
f"""
[THREAD STATE LEDGER] Runtime promotion completed and re-proven
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: server-side state changed
- Local verdict/state: promotion record carries every required field
- Latest known validation: assess_promotion_record valid=true; all namespaces re-proven
What changed:
- stable control runtime advanced to the promoted SHA and reloaded by the operator
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: resume normal workflow phases on the promoted runtime
- Do not do: promote again without a fresh record
- Resume from: docs/stable-runtime-promotion-runbook.md section 4
""",
)
)
EXAMPLES.append(
_example(
"rollback_required",
"""
[CONTROLLER HANDOFF] Runtime promotion rollback required
Server-side mutation ledger:
- gitea_create_issue_comment on #615 with the rollback evidence
Blockers:
- environment/tooling blocker: promoted runtime unhealthy, rollback required
""",
f"""
[THREAD STATE LEDGER] Runtime promoted runtime unhealthy, rollback required
What is true now:
- Runtime mode: unknown
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed after the promotion record
- Local verdict/state: promoted runtime failed namespace health; mutations blocked
- Latest known validation: namespace health probe reported EOF after reload
What changed:
- all PR/review/merge work stopped pending rollback to the previous runtime SHA
What is blocked:
- Blocker classification: environment/tooling blocker
Who/what acts next:
- Next actor: controller
- Required action: operator rolls back to the previous runtime SHA and re-proves every namespace
- Do not do: route around the unhealthy runtime or mutate from a dev/test runtime
- Resume from: docs/stable-runtime-promotion-runbook.md section 6
""",
)
)
EXAMPLES.append(
_example(
"duplicate_canonicalization_blocker",
@@ -336,6 +523,49 @@ Who/what acts next:
- Required action: implement #507 two-comment validator
- Do not do: recreate duplicate CTH issue
- Resume from: issue #507 body
""",
)
)
EXAMPLES.append(
_example(
"bound_worktree_missing_blocker",
"""
[CONTROLLER HANDOFF] Issue #618 — author mutation blocked
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- environment/tooling blocker: bound worktree missing; operator must recreate or repoint the worktree and reconnect
""",
"""
[THREAD STATE LEDGER] Issue #618 — author worktree binding unhealthy
What is true now:
- Issue state: open
- Server-side decision state: no server-side state changed
- Local verdict/state: author mutation tools fail closed consistently
- Latest known validation: runtime context reports workspace_healthy=false
- Role/profile: prgs-author
- Configured worktree path: branches/mcp-author-clean-ns (via GITEA_AUTHOR_WORKTREE)
- path_exists: false
- in_git_worktree_list: false
- inspected_git_root: null
What changed:
- nothing server-side; local env still points at a deleted role-bound worktree
What is blocked:
- Blocker classification: environment/tooling blocker
- Blocker detail: bound worktree missing; operator must recreate or repoint the worktree and reconnect
- create_issue and create_issue_comment (and other author mutations) agree: fail closed before API mutation
Who/what acts next:
- Next actor: operator
- Required action: recreate the worktree under branches/ (scripts/worktree-start or git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE to that path (or pass worktree_path), keep control checkout clean on master, reconnect the author MCP session, then re-run the mutation
- Do not do: retry mutations hoping create_issue_comment will still work while create_issue blocks; do not fall back to the control checkout or master
- Resume from: healthy author worktree binding + gitea_whoami + gitea_resolve_task_capability
""",
)
)
+716
View File
@@ -0,0 +1,716 @@
"""Read-only workflow dashboard for live queue / lease / next-safe-action (#605).
Builds a machine-readable + human-readable operational view so humans and LLMs
can see what is safe to work on without reconstructing state from comments.
Design rules:
* Read-only: never assigns work. Assignment still goes through
``gitea_allocate_next_work`` (#600).
* Never present blocked / terminal-locked / dependency-unmet items as safe.
* Prefer pure classification so unit tests can inject inventory (including
terminal-blocked queues from #593/#592/#587-style scenarios).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Mapping, Sequence
from allocator_service import (
ROLE_AUTHOR,
ROLE_CONTROLLER,
ROLE_MERGER,
ROLE_RECONCILER,
ROLE_REVIEWER,
OWNERSHIP_FOREIGN,
OWNERSHIP_UNKNOWN,
SKIP_CLAIMED_BY_OTHER_SESSION,
WorkCandidate,
classify_claim_ownership,
classify_skip,
expected_role_for_candidate,
sort_candidates,
)
DASHBOARD_VERSION = "1.0.0-issue-605"
# Roles the dashboard surfaces next-safe prompts for.
DASHBOARD_ROLES: tuple[str, ...] = (
ROLE_AUTHOR,
ROLE_REVIEWER,
ROLE_MERGER,
ROLE_RECONCILER,
ROLE_CONTROLLER,
)
# Exact operator prompts (fill-in tokens only — no self-selection).
PROMPT_AUTHOR = (
"AUTHOR session: call gitea_allocate_next_work(apply=true, role='author') "
"for {remote}/{org}/{repo}, then implement only the assigned issue under "
"branches/ and open/update its PR. Do not self-select outside the allocator."
)
PROMPT_REVIEWER = (
"REVIEWER session: call gitea_allocate_next_work(apply=true, role='reviewer') "
"for {remote}/{org}/{repo}, pin the assigned PR head SHA, submit exactly one "
"formal review verdict for that head. Do not merge."
)
PROMPT_MERGER = (
"MERGER session: call gitea_allocate_next_work(apply=true, role='merger') "
"for {remote}/{org}/{repo}, reassess the assigned approved head, and merge "
"only that exact head via gitea_merge_pr. Do not review."
)
PROMPT_RECONCILER = (
"RECONCILER session: call gitea_allocate_next_work(apply=true, role='reconciler') "
"for {remote}/{org}/{repo}, then perform only the assigned terminal "
"reconciliation (already-landed / post-merge cleanup). Do not approve or merge."
)
PROMPT_CONTROLLER = (
"CONTROLLER session: inspect gitea_workflow_dashboard + control-plane leases, "
"diagnose blocked/terminal-locked items for {remote}/{org}/{repo}, and schedule "
"exactly one fresh role-scoped cycle. Do not implement, review, or merge in-band."
)
PROMPT_IDLE = (
"IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. "
"Do not self-select. Re-run gitea_workflow_dashboard on the next cycle."
)
PROMPT_TERMINAL_BLOCK = (
"BLOCKED by terminal-review lock on PR #{terminal_pr} for {remote}/{org}/{repo}. "
"Resolve the terminal path for that exact PR before any other review/merge work. "
"Do not treat other open PRs as safe."
)
PROMPT_BLOCKED_ITEM = (
"NOT SAFE: {kind}#{number} is blocked ({reason}). Never present as next safe work."
)
@dataclass(frozen=True)
class QueueEntry:
kind: str
number: int
title: str
expected_role: str
safe_for_roles: tuple[str, ...]
badges: tuple[str, ...]
block_reason: str | None = None
head_sha: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"number": self.number,
"title": self.title,
"expected_role": self.expected_role,
"safe_for_roles": list(self.safe_for_roles),
"badges": list(self.badges),
"block_reason": self.block_reason,
"head_sha": self.head_sha,
"is_safe": self.block_reason is None and bool(self.safe_for_roles),
}
@dataclass(frozen=True)
class RoleNextAction:
role: str
status: str # safe | idle | blocked_terminal | none
target_kind: str | None
target_number: int | None
head_sha: str | None
prompt: str
reasons: tuple[str, ...] = ()
def as_dict(self) -> dict[str, Any]:
return {
"role": self.role,
"status": self.status,
"target_kind": self.target_kind,
"target_number": self.target_number,
"head_sha": self.head_sha,
"prompt": self.prompt,
"reasons": list(self.reasons),
"is_safe": self.status == "safe",
}
@dataclass
class DashboardSnapshot:
remote: str
org: str
repo: str
inventory_complete: bool
candidate_count: int
open_prs: list[QueueEntry] = field(default_factory=list)
open_issues: list[QueueEntry] = field(default_factory=list)
review_ready_prs: list[QueueEntry] = field(default_factory=list)
merge_ready_prs: list[QueueEntry] = field(default_factory=list)
author_remediation: list[QueueEntry] = field(default_factory=list)
discussion_issues: list[QueueEntry] = field(default_factory=list)
blocked_items: list[QueueEntry] = field(default_factory=list)
controller_needed: list[QueueEntry] = field(default_factory=list)
active_leases_by_role: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
stale_or_expired_leases: list[dict[str, Any]] = field(default_factory=list)
terminal_review_lock: dict[str, Any] | None = None
next_safe_by_role: dict[str, RoleNextAction] = field(default_factory=dict)
primary_next_safe_action: RoleNextAction | None = None
reasons: list[str] = field(default_factory=list)
dashboard_version: str = DASHBOARD_VERSION
def as_dict(self) -> dict[str, Any]:
return {
"success": self.inventory_complete and not any(
r.startswith("inventory incomplete") for r in self.reasons
),
"read_only": True,
"dashboard_version": self.dashboard_version,
"remote": self.remote,
"org": self.org,
"repo": self.repo,
"inventory_complete": self.inventory_complete,
"candidate_count": self.candidate_count,
"open_pr_queue": [e.as_dict() for e in self.open_prs],
"open_issue_queue": [e.as_dict() for e in self.open_issues],
"review_ready_prs": [e.as_dict() for e in self.review_ready_prs],
"merge_ready_prs": [e.as_dict() for e in self.merge_ready_prs],
"author_remediation": [e.as_dict() for e in self.author_remediation],
"discussion_issues": [e.as_dict() for e in self.discussion_issues],
"blocked_items": [e.as_dict() for e in self.blocked_items],
"controller_needed": [e.as_dict() for e in self.controller_needed],
"active_leases_by_role": {
role: list(items) for role, items in self.active_leases_by_role.items()
},
"stale_or_expired_leases": list(self.stale_or_expired_leases),
"terminal_review_lock": self.terminal_review_lock,
"next_safe_by_role": {
role: action.as_dict() for role, action in self.next_safe_by_role.items()
},
"primary_next_safe_action": (
self.primary_next_safe_action.as_dict()
if self.primary_next_safe_action
else None
),
"reasons": list(self.reasons),
"human_summary": format_human_summary(self),
}
def _scope_tokens(remote: str, org: str, repo: str) -> dict[str, str]:
return {"remote": remote, "org": org, "repo": repo}
def _prompt_for_role(
role: str,
*,
remote: str,
org: str,
repo: str,
terminal_pr: int | None = None,
idle: bool = False,
) -> str:
scope = _scope_tokens(remote, org, repo)
if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER):
return PROMPT_TERMINAL_BLOCK.format(terminal_pr=terminal_pr, **scope)
if idle:
return PROMPT_IDLE.format(role=role, **scope)
templates = {
ROLE_AUTHOR: PROMPT_AUTHOR,
ROLE_REVIEWER: PROMPT_REVIEWER,
ROLE_MERGER: PROMPT_MERGER,
ROLE_RECONCILER: PROMPT_RECONCILER,
ROLE_CONTROLLER: PROMPT_CONTROLLER,
}
return templates.get(role, PROMPT_CONTROLLER).format(**scope)
def _badges_for_candidate(c: WorkCandidate, *, terminal_pr: int | None) -> tuple[str, ...]:
badges: list[str] = []
if c.kind == "pr":
if c.request_changes_current_head:
badges.append("request-changes")
if c.approval_on_current_head and c.mergeable:
badges.append("merge-ready")
elif c.approval_on_current_head and not c.mergeable:
badges.append("approved-not-mergeable")
if c.approval_stale:
badges.append("approval-stale")
if c.approval_contaminated:
badges.append("contaminated")
if not c.approval_on_current_head and not c.request_changes_current_head:
badges.append("review-ready")
if terminal_pr is not None and c.number == terminal_pr:
badges.append("terminal-lock")
if terminal_pr is not None and c.number != terminal_pr:
badges.append("blocked-by-terminal")
else:
labels = set(c.labels)
if "status:ready" in labels:
badges.append("ready")
if "status:in-progress" in labels:
badges.append("in-progress")
if "status:blocked" in labels or c.blocked:
badges.append("blocked")
if "discussion" in labels or "type:discussion" in labels:
badges.append("discussion")
if c.dependency_unmet:
badges.append("dependency-unmet")
if c.already_claimed_elsewhere:
badges.append("claimed")
if c.blocked:
badges.append("blocked")
# de-dupe preserve order
seen: set[str] = set()
out: list[str] = []
for b in badges:
if b not in seen:
seen.add(b)
out.append(b)
return tuple(out)
def _entry_for_candidate(
c: WorkCandidate,
*,
terminal_pr: int | None,
claim_ownership: str | None = None,
) -> QueueEntry:
expected = expected_role_for_candidate(c)
badges = _badges_for_candidate(c, terminal_pr=terminal_pr)
claimed_by_other = claim_ownership in (OWNERSHIP_FOREIGN, OWNERSHIP_UNKNOWN)
if claimed_by_other and "claimed" not in badges:
badges = tuple(list(badges) + ["claimed"])
safe_roles: list[str] = []
block_reason: str | None = None
# Global hard blocks (never safe for any worker role).
if c.blocked or "status:blocked" in c.labels:
block_reason = "status blocked"
elif c.dependency_unmet:
block_reason = c.dependency_reason or "unmet dependency"
elif claimed_by_other:
# #765: never advertise another controller's active task as safe work.
block_reason = (
f"{SKIP_CLAIMED_BY_OTHER_SESSION}: active lease held by another "
"controller"
)
elif c.already_claimed_elsewhere:
block_reason = "already claimed elsewhere"
elif c.kind == "pr" and not (c.head_sha or "").strip():
block_reason = "missing head_sha pin"
elif (
terminal_pr is not None
and c.kind == "pr"
and c.number != terminal_pr
):
# Other PRs remain visible but are not safe for review/merge while a
# terminal lock is active (#593/#592/#587-style queue).
block_reason = f"active terminal-review lock on PR #{terminal_pr}"
if block_reason is None:
# Safe only for the expected role, and only when classify_skip agrees.
skip = classify_skip(
c,
role=expected,
terminal_pr=terminal_pr,
claim_ownership=claim_ownership,
)
if skip is None:
safe_roles.append(expected)
else:
block_reason = skip
return QueueEntry(
kind=c.kind,
number=c.number,
title=c.title or "",
expected_role=expected,
safe_for_roles=tuple(safe_roles),
badges=badges,
block_reason=block_reason,
head_sha=c.head_sha,
)
def _partition_leases(
leases: Sequence[dict[str, Any]] | None,
) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]:
by_role: dict[str, list[dict[str, Any]]] = {r: [] for r in DASHBOARD_ROLES}
stale: list[dict[str, Any]] = []
for raw in leases or ():
if not isinstance(raw, dict):
continue
role = str(raw.get("role") or raw.get("owner_role") or "unknown").strip().lower()
status = str(raw.get("status") or raw.get("lease_status") or "active").strip().lower()
entry = dict(raw)
if status in ("expired", "stale", "released", "moot") or raw.get("stale") or raw.get(
"expired"
):
stale.append(entry)
continue
if role in by_role:
by_role[role].append(entry)
else:
by_role.setdefault(role, []).append(entry)
return by_role, stale
def _first_safe_for_role(
entries: Iterable[QueueEntry],
role: str,
) -> QueueEntry | None:
for entry in entries:
if role in entry.safe_for_roles and entry.block_reason is None:
return entry
return None
def _role_next_action(
role: str,
*,
entries: Sequence[QueueEntry],
remote: str,
org: str,
repo: str,
terminal_pr: int | None,
) -> RoleNextAction:
# Terminal lock blocks reviewer/merger from non-terminal work.
if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER):
terminal_entry = next(
(
e
for e in entries
if e.kind == "pr" and e.number == terminal_pr and role in e.safe_for_roles
),
None,
)
if terminal_entry is None:
return RoleNextAction(
role=role,
status="blocked_terminal",
target_kind="pr",
target_number=terminal_pr,
head_sha=None,
prompt=_prompt_for_role(
role,
remote=remote,
org=org,
repo=repo,
terminal_pr=terminal_pr,
),
reasons=(
f"active terminal-review lock on PR #{terminal_pr}; "
"no other review/merge target is safe",
),
)
return RoleNextAction(
role=role,
status="safe",
target_kind="pr",
target_number=terminal_pr,
head_sha=terminal_entry.head_sha,
prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
reasons=(f"terminal-path PR #{terminal_pr} is the only safe target",),
)
if role == ROLE_CONTROLLER:
needed = [e for e in entries if e.expected_role == ROLE_CONTROLLER or e.block_reason]
if not needed:
return RoleNextAction(
role=role,
status="idle",
target_kind=None,
target_number=None,
head_sha=None,
prompt=_prompt_for_role(
role, remote=remote, org=org, repo=repo, idle=True
),
reasons=("no controller-needed items",),
)
target = needed[0]
return RoleNextAction(
role=role,
status="safe",
target_kind=target.kind,
target_number=target.number,
head_sha=target.head_sha,
prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
reasons=(target.block_reason or "controller diagnosis required",),
)
hit = _first_safe_for_role(entries, role)
if hit is None:
return RoleNextAction(
role=role,
status="idle",
target_kind=None,
target_number=None,
head_sha=None,
prompt=_prompt_for_role(
role, remote=remote, org=org, repo=repo, idle=True
),
reasons=(f"no safe assignable work for role '{role}'",),
)
return RoleNextAction(
role=role,
status="safe",
target_kind=hit.kind,
target_number=hit.number,
head_sha=hit.head_sha,
prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
reasons=(f"highest-ranked safe candidate for role '{role}'",),
)
def build_workflow_dashboard(
*,
candidates: Sequence[WorkCandidate],
remote: str = "prgs",
org: str = "Scaled-Tech-Consulting",
repo: str = "Gitea-Tools",
leases: Sequence[dict[str, Any]] | None = None,
terminal_pr: int | None = None,
terminal_lock: dict[str, Any] | None = None,
inventory_complete: bool = True,
inventory_reasons: Sequence[str] | None = None,
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
session_id: str | None = None,
controller_instance_id: str | None = None,
) -> DashboardSnapshot:
"""Build a full dashboard snapshot from injected inventory (pure).
*claims* (#765) maps ``(kind, number)`` to the live lease holding that work
item. Items claimed by a different controller are never presented as safe
next work for this one.
"""
reasons = [str(r) for r in (inventory_reasons or ()) if str(r).strip()]
if not inventory_complete:
reasons.append(
"inventory incomplete: refuse to present partial queues as complete "
"(fail closed, #605/#758)"
)
ranked = sort_candidates(list(candidates))
entries = [
_entry_for_candidate(
c,
terminal_pr=terminal_pr,
claim_ownership=classify_claim_ownership(
(claims or {}).get((c.kind, int(c.number))),
session_id=session_id,
controller_instance_id=controller_instance_id,
),
)
for c in ranked
]
open_prs = [e for e in entries if e.kind == "pr"]
open_issues = [e for e in entries if e.kind == "issue"]
review_ready = [
e
for e in open_prs
if "review-ready" in e.badges
and e.block_reason is None
and ROLE_REVIEWER in e.safe_for_roles
]
merge_ready = [
e
for e in open_prs
if "merge-ready" in e.badges
and e.block_reason is None
and ROLE_MERGER in e.safe_for_roles
]
author_remediation = [
e
for e in open_prs
if "request-changes" in e.badges
and e.block_reason is None
and ROLE_AUTHOR in e.safe_for_roles
]
discussion = [
e
for e in open_issues
if "discussion" in e.badges
]
blocked = [e for e in entries if e.block_reason is not None]
controller_needed = [
e
for e in entries
if e.expected_role == ROLE_CONTROLLER
or (e.block_reason and "contaminated" in (e.badges or ()))
or "contaminated" in e.badges
]
leases_by_role, stale_leases = _partition_leases(leases)
term_payload = None
if terminal_lock is not None:
term_payload = dict(terminal_lock)
elif terminal_pr is not None:
term_payload = {
"active": True,
"terminal_pr": terminal_pr,
"state": "locked",
}
next_by_role: dict[str, RoleNextAction] = {}
for role in DASHBOARD_ROLES:
next_by_role[role] = _role_next_action(
role,
entries=entries,
remote=remote,
org=org,
repo=repo,
terminal_pr=terminal_pr,
)
# Primary next action prefers in-flight PR work, then author issues.
primary: RoleNextAction | None = None
for role in (ROLE_REVIEWER, ROLE_MERGER, ROLE_AUTHOR, ROLE_RECONCILER, ROLE_CONTROLLER):
action = next_by_role[role]
if action.status == "safe":
primary = action
break
if primary is None:
# Prefer an explicit terminal block signal over generic idle.
for role in (ROLE_REVIEWER, ROLE_MERGER):
if next_by_role[role].status == "blocked_terminal":
primary = next_by_role[role]
break
if primary is None:
primary = next_by_role[ROLE_AUTHOR]
# Incomplete inventory: strip all safe flags / never suggest work.
if not inventory_complete:
for role, action in list(next_by_role.items()):
next_by_role[role] = RoleNextAction(
role=role,
status="none",
target_kind=None,
target_number=None,
head_sha=None,
prompt=(
f"BLOCKED: inventory incomplete for {remote}/{org}/{repo}; "
"do not select work. Re-run after a complete listing."
),
reasons=tuple(reasons) or ("inventory incomplete",),
)
primary = next_by_role[ROLE_CONTROLLER]
review_ready = []
merge_ready = []
author_remediation = []
return DashboardSnapshot(
remote=remote,
org=org,
repo=repo,
inventory_complete=inventory_complete,
candidate_count=len(ranked),
open_prs=open_prs,
open_issues=open_issues,
review_ready_prs=review_ready,
merge_ready_prs=merge_ready,
author_remediation=author_remediation,
discussion_issues=discussion,
blocked_items=blocked,
controller_needed=controller_needed,
active_leases_by_role=leases_by_role,
stale_or_expired_leases=stale_leases,
terminal_review_lock=term_payload,
next_safe_by_role=next_by_role,
primary_next_safe_action=primary,
reasons=reasons,
)
def format_human_summary(snapshot: DashboardSnapshot) -> str:
"""Compact human-readable multi-line summary for menus and operators."""
lines: list[str] = []
lines.append(
f"Workflow dashboard v{snapshot.dashboard_version}"
f"{snapshot.remote}/{snapshot.org}/{snapshot.repo}"
)
lines.append(
f"Inventory: complete={snapshot.inventory_complete} "
f"candidates={snapshot.candidate_count}"
)
if snapshot.terminal_review_lock:
tpr = snapshot.terminal_review_lock.get("terminal_pr")
lines.append(f"Terminal review lock: ACTIVE on PR #{tpr}")
else:
lines.append("Terminal review lock: none")
lines.append(
f"Open PRs: {len(snapshot.open_prs)} | Open issues: {len(snapshot.open_issues)}"
)
lines.append(
f"Review-ready: {len(snapshot.review_ready_prs)} | "
f"Merge-ready: {len(snapshot.merge_ready_prs)} | "
f"Author remediation: {len(snapshot.author_remediation)}"
)
lines.append(
f"Blocked: {len(snapshot.blocked_items)} | "
f"Controller-needed: {len(snapshot.controller_needed)} | "
f"Discussion: {len(snapshot.discussion_issues)}"
)
active_counts = {
role: len(items)
for role, items in snapshot.active_leases_by_role.items()
if items
}
if active_counts:
parts = [f"{role}={n}" for role, n in sorted(active_counts.items())]
lines.append("Active leases by role: " + ", ".join(parts))
else:
lines.append("Active leases by role: none")
lines.append(
f"Stale/expired leases: {len(snapshot.stale_or_expired_leases)}"
)
# Never list blocked items as safe.
if snapshot.blocked_items:
lines.append("Blocked (NOT safe):")
for entry in snapshot.blocked_items[:12]:
lines.append(
f" - {entry.kind}#{entry.number}: {entry.block_reason}"
)
lines.append(
" "
+ PROMPT_BLOCKED_ITEM.format(
kind=entry.kind,
number=entry.number,
reason=entry.block_reason or "blocked",
)
)
lines.append("Next safe action by role:")
for role in DASHBOARD_ROLES:
action = snapshot.next_safe_by_role.get(role)
if action is None:
continue
target = (
f"{action.target_kind}#{action.target_number}"
if action.target_number is not None
else "none"
)
lines.append(
f" - {role}: status={action.status} target={target} "
f"safe={action.status == 'safe'}"
)
lines.append(f" prompt: {action.prompt}")
if snapshot.primary_next_safe_action:
p = snapshot.primary_next_safe_action
lines.append(
f"Primary next: role={p.role} status={p.status} "
f"target={p.target_kind}#{p.target_number if p.target_number else 'none'}"
)
lines.append(f" prompt: {p.prompt}")
if snapshot.reasons:
lines.append("Notes:")
for r in snapshot.reasons:
lines.append(f" - {r}")
lines.append(
"Assignment still requires gitea_allocate_next_work; "
"this dashboard never self-selects exclusive work."
)
return "\n".join(lines)
+37 -7
View File
@@ -278,12 +278,19 @@ def assess_root_source_mutation(
current_branch: str | None = None,
locked_issue_number: int | None = None,
role_kind: str | None = None,
mutation_task: str | None = None,
) -> dict[str, Any]:
"""Fail closed for diagnostic/source edits on the control/root checkout.
Allowed only when the active workspace is under ``branches/``. Dirty
tracked source/test files on the control checkout always block, including
temporary/diagnostic/test-only intent.
#749: ``create_issue`` is a pure remote mutation with no local tree write.
When *mutation_task* is create_issue and the control checkout has no dirty
source/test files, the missing-worktree signal is suppressed so the
sanctioned bootstrap path can proceed. Dirty roots and every other task
still fail closed.
"""
role = (role_kind or "").strip().lower()
if role == "reconciler":
@@ -302,6 +309,7 @@ def assess_root_source_mutation(
dirty_src = dirty_source_files(porcelain_status)
reasons: list[str] = []
blocker_kind: str | None = None
create_issue_bootstrap = False
if not under_branches and workspace == root and dirty_src:
# Root workspace with source dirtiness is unattributed root WIP.
@@ -313,32 +321,50 @@ def assess_root_source_mutation(
)
blocker_kind = BLOCKER_ROOT_DIAGNOSTIC_EDIT
# Lazy import keeps workflow_scope_guard free of circular import at module load.
try:
import create_issue_bootstrap as _cib
except Exception: # pragma: no cover - import always available in-tree
_cib = None
if (
not under_branches
and workspace == root
and not dirty_src
and role == "author"
):
# Explicit missing-worktree signal for force-on author entrypoints.
reasons.append(
"author source/test mutation from the stable control checkout is "
"forbidden; bind an issue-backed worktree under branches/ first"
)
blocker_kind = BLOCKER_MISSING_WORKTREE
if _cib is not None and _cib.is_create_issue_task(mutation_task):
# #749: clean-root create_issue is the sanctioned bootstrap path.
create_issue_bootstrap = True
else:
# Explicit missing-worktree signal for force-on author entrypoints.
reasons.append(
"author source/test mutation from the stable control checkout is "
"forbidden; bind an issue-backed worktree under branches/ first"
)
blocker_kind = BLOCKER_MISSING_WORKTREE
if reasons:
kind = blocker_kind or BLOCKER_ROOT_DIAGNOSTIC_EDIT
next_action = _NEXT_ACTIONS[kind]
if (
kind == BLOCKER_ROOT_DIAGNOSTIC_EDIT
and _cib is not None
and _cib.is_create_issue_task(mutation_task)
):
next_action = _cib.EXACT_NEXT_ACTION_BOOTSTRAP
return {
"proven": False,
"block": True,
"blocker_kind": kind,
"exact_next_action": _NEXT_ACTIONS[kind],
"exact_next_action": next_action,
"reasons": reasons,
"dirty_source_files": dirty_src,
"workspace_path": workspace,
"canonical_repo_root": root,
"under_branches": under_branches,
"locked_issue_number": locked_issue_number,
"create_issue_bootstrap": False,
}
return {
"proven": True,
@@ -351,6 +377,7 @@ def assess_root_source_mutation(
"canonical_repo_root": root,
"under_branches": under_branches,
"locked_issue_number": locked_issue_number,
"create_issue_bootstrap": create_issue_bootstrap,
}
@@ -365,6 +392,7 @@ def assess_production_mutation_guards(
role_kind: str | None = None,
require_author_lock: bool = False,
in_test_mode: bool = False,
mutation_task: str | None = None,
) -> dict[str, Any]:
"""Compose root + scope production guards when they must be active (#683)."""
if not production_guards_active(in_test_mode=in_test_mode):
@@ -385,6 +413,7 @@ def assess_production_mutation_guards(
current_branch=current_branch,
locked_issue_number=locked_issue_number,
role_kind=role_kind,
mutation_task=mutation_task,
)
if root_assess["block"]:
return {**root_assess, "skipped": False}
@@ -408,6 +437,7 @@ def assess_production_mutation_guards(
"skipped": False,
"root": root_assess,
"scope": scope_assess,
"create_issue_bootstrap": bool(root_assess.get("create_issue_bootstrap")),
}