Compare commits

...
Author SHA1 Message Date
jcwalker3andClaude Opus 4.8 aa4fe1cc7b fix(guard): make shell segment splitting quote-aware (Closes #787)
Review #497 on PR #789 found that adding `&` to the separator alternation
created a new false-positive class: the splitter was quote-unaware, so any
quoted text carrying an ampersand and a kill verb classified as a manual
daemon kill. Three commands regressed against base 35e94e10, all of which
merely mention the canonical kill string:

    git commit -m "block sleep 1 & pkill -f mcp_server.py as recovery"
    echo "docs: sleep 1 & pkill -f mcp_server.py is now detected"
    grep -rn "sleep 1 & pkill -f mcp_server.py" docs/

A false contamination marker fails review, merge, close and completion
mutations closed and only a reconciler may clear it, so this is an
operator-visible stall rather than a warning.

F1: replace the `_SEGMENT_SPLIT_RE` alternation with a quote-aware scan.
`_iter_active` yields only the character positions where a metacharacter is
syntactically active — outside single and double quotes, not backslash
escaped — and `_split_segments` separates only there. This fixes the class
rather than the three named instances, and also retires the `;` and `|`
false positives that predate #787. `&&` and `||` are still consumed whole,
so logical-separator precedence is unchanged.

F3: `_strip_subshell` now removes a trailing `)` only when it closes a
leading `(` this call stripped, so `kill $(pgrep -f myapp)` is no longer
mangled into `kill $(pgrep -f myapp`; and `_is_redirection` keeps `2>&1`,
`>&2` and `&>log` from being read as background separators. Neither changed
a verdict at the rejected head, but both are corrected here because the
splitter was reworked for F1.

Nine focused cases added: the three F1 commands, double-quoted, single-quoted
and backslash-escaped ampersands, the POSIX rule that a backslash does not
escape inside single quotes, the `;`/`|` class, the two F3 forms, and a
record-tool case asserting no marker is written for a quoted mention.

Focused suite 64 passed (47 at base 35e94e10, 55 at rejected head 6b58f04).
Full suite 11 failed, 4190 passed, 6 skipped; the same 11 failures occur
test-for-test at base and are unrelated to this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 20:45:17 -05:00
jcwalker3andClaude Opus 4.8 6b58f04d39 fix(guard): split on & and unwrap subshells so real daemon kills classify (Closes #787)
`runtime_recovery_guard._SEGMENT_SPLIT_RE` claimed to split a compound command
line "on shell separators", but omitted the background-job separator `&`, and no
caller stripped a subshell wrapper before tokenising. `_analyse_kill_segment`
only inspects the first command token of a segment, so in both forms the kill
verb never reached the classifier.

Measured against the previous implementation at 35e94e10:

    sleep 1 & pkill -f mcp_server.py   ->  process_kill False, contamination False
    (pkill -f mcp_server.py)           ->  process_kill False, contamination False

Changes:

- Add `&` to `_SEGMENT_SPLIT_RE` as `(?:\|\||&&|[|;&\n])`; the two-character
  logical forms stay first so `&&` and `||` are consumed whole.
- Add `_strip_subshell()` and apply it in `_split_segments()`, removing leading
  `(` and trailing `)` (including nested wrappers) before tokenising.

Both forms now classify as contamination with reason class `manual_daemon_kill`,
and the record tool writes a durable marker for each. Direct-command behaviour is
unchanged. The contamination model, gated-task set, marker lifecycle, and
reconciler-only clearing path are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 19:56:00 -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
63 changed files with 18164 additions and 301 deletions
+476 -10
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,22 @@ 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
@@ -144,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:
@@ -203,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:
@@ -214,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():
@@ -274,6 +377,113 @@ def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
)
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,
*,
@@ -287,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:
@@ -328,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 {
@@ -369,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,
@@ -414,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"
@@ -460,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"
@@ -583,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"
@@ -614,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")),
+1
View File
@@ -77,6 +77,7 @@ MUTATION_TASKS = frozenset({
"create_issue",
"comment_issue",
"close_issue",
"edit_issue",
"mark_issue",
"lock_issue",
"set_issue_labels",
+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
+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.
+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
+4 -1
View File
@@ -706,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
@@ -1241,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.
+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, ()):
+2177 -165
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
+413 -29
View File
@@ -22,6 +22,45 @@ 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
@@ -40,6 +79,22 @@ 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:
@@ -87,6 +142,130 @@ def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
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,
*,
@@ -107,6 +286,10 @@ def assess_dead_session_lock_recovery(
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.
@@ -218,31 +401,109 @@ def assess_dead_session_lock_recovery(
)
evidence["dirty_files"] = dirty_files
# ── Head agreement: local == remote == PR ───────────────────────────────
# ── 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")
if not remote_head:
reasons.append(
f"remote head for branch '{locked_branch}' could not be determined"
)
if local_head and remote_head and local_head != remote_head:
reasons.append(
f"local head {local_head} does not match remote branch head {remote_head}"
)
# #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
if local_head and pr_head != local_head:
reasons.append(
f"open PR #{pr_number} head {pr_head} does not match local head "
f"{local_head}"
)
# 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)
@@ -335,16 +596,34 @@ def assess_dead_session_lock_recovery(
if reasons:
return _result(REFUSED, False, reasons, evidence)
return _result(
RECOVERY_SANCTIONED,
True,
[
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"
],
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(
@@ -374,10 +653,16 @@ def owning_pr_recovery_evidence(
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 local
and remote heads. Nothing here is caller-supplied: every field is copied
from evidence the assessor built out of durable lock state plus live
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
@@ -391,13 +676,28 @@ def owning_pr_recovery_evidence(
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 pr_head != local_head or pr_head != remote_head:
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)
@@ -410,6 +710,77 @@ def owning_pr_recovery_evidence(
"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,
}
@@ -418,7 +789,13 @@ def build_recovery_record(
*,
recovered_at: str,
) -> dict[str, Any]:
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6)."""
"""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,
@@ -429,8 +806,15 @@ def build_recovery_record(
"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"),
+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:
+193 -1
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] = (),
+16 -4
View File
@@ -122,18 +122,30 @@ def _assess_owning_pr_exemption(
f"the recovered branch '{token_branch}' (no owning-PR exemption)"
)
return False, notes
if not token_head or not only_sha or only_sha != token_head:
# #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'} "
"(no owning-PR exemption)"
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 {token_head}); not duplicate work"
f"head {only_sha}); not duplicate work"
]
+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"),
}
+637
View File
@@ -0,0 +1,637 @@
"""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, Iterator
# 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. The background separator
# ``&`` is a separator too: without it ``sleep 1 & pkill -f mcp_server.py`` was
# a single segment whose command position held ``sleep``, so the kill was never
# classified (#787).
#
# Splitting is *quote-aware*, and a regex alternation cannot express that, so
# the scan below replaces the earlier ``_SEGMENT_SPLIT_RE`` pattern. A separator
# only separates where it is syntactically active: outside single and double
# quotes, and not backslash-escaped. Without that, adding ``&`` made every
# benign mention of the canonical kill string classify as a real kill — a commit
# message quoting ``sleep 1 & pkill -f mcp_server.py``, an ``echo`` of the same
# sentence, a ``grep`` for it — and a false contamination marker fails review,
# merge, close and completion mutations closed until a reconciler clears it (PR
# #789 review finding F1). Quote-awareness is not specific to ``&``: it also
# retires the same false-positive class that ``;`` and ``|`` carried before #787.
_SEPARATOR_CHARS = frozenset("|&;\n")
#: Two-character logical separators, consumed whole so ``&&`` and ``||`` are
#: never split into single characters leaving a stray operator behind.
_LOGICAL_SEPARATORS = ("&&", "||")
_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 _iter_active(text: str) -> Iterator[tuple[int, str]]:
"""Yield ``(index, char)`` for every *syntactically active* character.
Active means outside single and double quotes and not backslash-escaped
the positions where a shell metacharacter actually carries its meaning.
Quoted runs, the quote characters themselves, and escaped characters are
skipped, so a separator written inside a commit message or a ``grep``
pattern is literal text rather than syntax. A backslash escapes nothing
inside single quotes, matching POSIX.
An unterminated quote swallows the rest of the line, exactly as it does for
the shell which would reject such a command as a syntax error rather than
run its tail, so nothing executable hides behind it.
"""
quote: str | None = None
index = 0
end = len(text)
while index < end:
char = text[index]
if quote == "'":
if char == "'":
quote = None
index += 1
elif quote == '"':
if char == "\\" and index + 1 < end:
index += 2
else:
if char == '"':
quote = None
index += 1
elif char == "\\" and index + 1 < end:
index += 2
elif char in ("'", '"'):
quote = char
index += 1
else:
yield index, char
index += 1
def _is_redirection(command: str, index: int, active: frozenset[int]) -> bool:
"""Is the ``&``/``|`` at *index* part of a redirection, not a separator?
``2>&1`` and ``>&2`` put the character immediately after a redirection
operator, and ``&>log`` immediately before one; in neither position does it
separate commands. Without this, ``a 2>&1`` split into ``['a 2>', '1']``
(PR #789 review finding F3).
"""
previous = command[index - 1] if index else ""
if previous in ("<", ">") and (index - 1) in active:
return True
return (
command[index] == "&"
and command[index + 1:index + 2] == ">"
and (index + 1) in active
)
def _closes_leading_paren(body: str) -> bool:
"""Does *body* end with the active ``)`` matching a stripped leading ``(``?"""
if not body.endswith(")"):
return False
depth = 0
for index, char in _iter_active(body):
if char == "(":
depth += 1
elif char == ")":
if depth == 0:
return index == len(body) - 1
depth -= 1
return False
def _strip_subshell(segment: str) -> str:
"""Remove subshell wrappers so ``(pkill -f mcp_server.py)`` is classified.
The parentheses are shell syntax, not part of the simple command, so a
wrapped kill otherwise put ``(pkill`` in command position and never
reached the kill classifier (#787). Nested wrappers are unwrapped too.
A trailing ``)`` is removed only when it closes a leading ``(`` this call
stripped. Removing one unconditionally mangled balanced command
substitution ``kill $(pgrep -f myapp)`` became ``kill $(pgrep -f myapp``
(PR #789 review finding F3). An unmatched leading ``(`` is still dropped on
its own, because splitting a wrapped compound orphans the opening half.
"""
stripped = segment.strip()
while stripped.startswith("("):
body = stripped[1:].strip()
if _closes_leading_paren(body):
body = body[:-1].strip()
stripped = body
return stripped
def _split_segments(command: str) -> list[str]:
"""Split *command* into simple commands on syntactically active separators."""
active = frozenset(index for index, _ in _iter_active(command))
segments: list[str] = []
start = 0
index = 0
end = len(command)
while index < end:
char = command[index]
if (
char not in _SEPARATOR_CHARS
or index not in active
or (char in "&|" and _is_redirection(command, index, active))
):
index += 1
continue
width = (
2
if command[index:index + 2] in _LOGICAL_SEPARATORS
and (index + 1) in active
else 1
)
segments.append(command[start:index])
index += width
start = index
segments.append(command[start:])
return [seg for seg in (_strip_subshell(seg) for seg in segments) if seg]
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.
+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,
}
+54
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",
@@ -462,13 +476,53 @@ def preflight_task_matches(
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."
)
),
}
@@ -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()
+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())
@@ -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)
@@ -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,670 @@
"""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
# ── #787: background separator and subshell forms reach the classifier ───────
def test_background_separator_kill_is_contamination():
result = guard.classify_recovery_command("sleep 1 & 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_subshell_wrapped_kill_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_further_background_and_subshell_forms_are_contamination():
for command in (
"pkill -f mcp_server.py &",
"( sudo pkill -f mcp_server.py )",
"((pkill -f gitea_mcp_server))",
"sleep 1 & killall mcp_server",
"(ps aux | grep mcp_server) & pkill -f mcp_server.py",
):
result = guard.classify_recovery_command(command)
assert result["contamination"] is True, command
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL, command
def test_logical_operators_are_not_split_into_single_characters():
# ``&&``/``||`` must still be consumed whole by the separator scan.
assert guard._split_segments("a && b || c") == ["a", "b", "c"]
assert guard._split_segments("a & b") == ["a", "b"]
assert guard._split_segments("(a)") == ["a"]
assert guard._split_segments("a; b\nc | d") == ["a", "b", "c", "d"]
# ── #789 F1: separators only separate outside quoted or escaped text ─────────
# The three commands the PR #789 review measured as regressions at head
# 6b58f04: each merely *mentions* the canonical kill string inside quotes.
F1_QUOTED_COMMANDS = (
'git commit -m "block sleep 1 & pkill -f mcp_server.py as recovery"',
'echo "docs: sleep 1 & pkill -f mcp_server.py is now detected"',
'grep -rn "sleep 1 & pkill -f mcp_server.py" docs/',
)
def test_quoted_ampersand_examples_from_review_f1_are_not_kills():
for command in F1_QUOTED_COMMANDS:
result = guard.classify_recovery_command(command)
assert result["process_kill"] is False, command
assert result["contamination"] is False, command
assert result["reason_class"] is None, command
def test_ampersand_inside_double_quotes_is_not_a_separator():
assert guard._split_segments('echo "a & b"') == ['echo "a & b"']
result = guard.classify_recovery_command(
'echo "restart it: sleep 1 & pkill -f mcp_server.py"'
)
assert result["process_kill"] is False
assert result["contamination"] is False
def test_ampersand_inside_single_quotes_is_not_a_separator():
assert guard._split_segments("echo 'a & b'") == ["echo 'a & b'"]
result = guard.classify_recovery_command(
"git commit -m 'sleep 1 & pkill -f mcp_server.py stays quoted'"
)
assert result["process_kill"] is False
assert result["contamination"] is False
def test_backslash_escaped_ampersand_is_not_a_separator():
command = r"echo a \& pkill -f mcp_server.py"
assert guard._split_segments(command) == [command]
result = guard.classify_recovery_command(command)
assert result["process_kill"] is False
assert result["contamination"] is False
def test_backslash_does_not_escape_inside_single_quotes():
# POSIX: a backslash is literal inside single quotes, so the closing quote
# still closes and the following ``&`` is a genuinely active separator.
command = r"echo 'a\' & pkill -f mcp_server.py"
assert guard._split_segments(command) == [r"echo 'a\'", "pkill -f mcp_server.py"]
assert guard.classify_recovery_command(command)["contamination"] is True
def test_quote_awareness_also_retires_the_pre_existing_semicolon_and_pipe_cases():
# ``;`` and ``|`` misclassified quoted text before #787 as well. The fix is
# the quote-unawareness, not the ``&`` instance the issue happens to name.
for command in (
'git commit -m "fix; pkill -f mcp_server.py"',
'git commit -m "fix | pkill -f mcp_server.py"',
):
result = guard.classify_recovery_command(command)
assert result["process_kill"] is False, command
assert result["contamination"] is False, command
# ── #789 F3: subshell stripping and redirection stay syntactically honest ────
def test_command_substitution_is_not_mangled_by_subshell_stripping():
# Only a wrapper this call opened may be unwrapped; a ``)`` closing ``$(``
# must survive intact.
assert guard._strip_subshell("kill $(pgrep -f myapp)") == "kill $(pgrep -f myapp)"
result = guard.classify_recovery_command("kill $(pgrep -f myapp)")
assert result["contamination"] is False
assert result["ambiguous"] is True
def test_redirection_is_not_treated_as_a_background_separator():
assert guard._split_segments("a 2>&1") == ["a 2>&1"]
assert guard._split_segments("a &> log") == ["a &> log"]
assert guard._split_segments("pkill -f mcp_server.py 2>&1") == [
"pkill -f mcp_server.py 2>&1"
]
result = guard.classify_recovery_command("pkill -f mcp_server.py 2>&1")
assert result["contamination"] is True
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
# ── 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_user_scoped_pkill_of_unrelated_app_is_not_contamination():
# ``-u`` consumes ``mcpuser``; the surviving operand names no daemon (#787).
result = guard.classify_recovery_command("pkill -u mcpuser -f myapp")
assert result["process_kill"] is True
assert result["contamination"] is False
assert result["ambiguous"] is False
def test_commit_message_quoting_the_kill_string_is_not_a_kill():
result = guard.classify_recovery_command(
'git commit -m "block pkill -f mcp_server.py as workflow recovery"'
)
assert result["process_kill"] is False
assert result["contamination"] 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_background_separator_kill():
_clear_marker()
res = srv.gitea_record_daemon_process_kill_attempt(
command="sleep 1 & 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_subshell_wrapped_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_does_not_mark_a_quoted_mention_of_the_kill_string():
# The marker is what fails review/merge/close closed and only a reconciler
# may clear it, so a quoted mention must never create one (PR #789 F1).
for command in F1_QUOTED_COMMANDS:
_clear_marker()
res = srv.gitea_record_daemon_process_kill_attempt(command=command, remote="prgs")
assert res["contaminated"] is False, command
assert res["marked"] is False, command
assert srv._load_runtime_recovery_marker("prgs") is None, command
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()
+10 -1
View File
@@ -73,12 +73,21 @@ def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
def sanctioned_token(
issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
):
"""The evidence shape the server derives from a granted recovery."""
"""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,
}
@@ -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()
+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)
+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
+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()
+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
""",
)
)
+40 -4
View File
@@ -14,7 +14,7 @@ Design rules:
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Sequence
from typing import Any, Iterable, Mapping, Sequence
from allocator_service import (
ROLE_AUTHOR,
@@ -22,7 +22,11 @@ from allocator_service import (
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,
@@ -265,9 +269,13 @@ 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
@@ -276,6 +284,12 @@ def _entry_for_candidate(
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():
@@ -291,7 +305,12 @@ def _entry_for_candidate(
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)
skip = classify_skip(
c,
role=expected,
terminal_pr=terminal_pr,
claim_ownership=claim_ownership,
)
if skip is None:
safe_roles.append(expected)
else:
@@ -450,8 +469,16 @@ def build_workflow_dashboard(
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)."""
"""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(
@@ -461,7 +488,16 @@ def build_workflow_dashboard(
ranked = sort_candidates(list(candidates))
entries = [
_entry_for_candidate(c, terminal_pr=terminal_pr) for c in ranked
_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"]