Compare commits

...
Author SHA1 Message Date
jcwalker3 f1e4809930 Merge branch 'master' into fix/issue-790-slice-a-heartbeat-policy 2026-07-23 12:31:44 -05:00
sysadmin 1c455b6ec0 Merge pull request 'feat(webui): read-only system-health API (Closes #634)' (#813) from feat/issue-634-readonly-system-health-api into master 2026-07-23 04:14:33 -05:00
jcwalker3 dc1d0e045f Merge branch 'master' into fix/issue-790-slice-a-heartbeat-policy 2026-07-23 01:13:11 -05:00
jcwalker3 6868b345ee Merge branch 'master' into feat/issue-634-readonly-system-health-api 2026-07-23 01:12:52 -05:00
sysadmin 4f3a464a90 Merge pull request 'fix: authoritative cross-role generic queue allocation (Closes #840)' (#841) from fix/issue-840-cross-role-queue-allocation into master 2026-07-23 00:37:55 -05:00
jcwalker3 badc4e636b Merge branch 'master' into fix/issue-790-slice-a-heartbeat-policy 2026-07-23 00:06:18 -05:00
jcwalker3 da6a864463 Merge branch 'master' into feat/issue-634-readonly-system-health-api 2026-07-23 00:06:06 -05:00
sysadmin 9468dd624d merge master into fix/issue-840-cross-role-queue-allocation 2026-07-23 00:06:09 -04:00
sysadminandClaude Opus 4.8 648d9464ba fix: authoritative cross-role generic queue allocation (Closes #840)
Add controller-owned cross_role allocation mode that inspects the full
queue and returns one selection with required role/profile/action and
lease evidence. Document process_work_queue routing, normalize
controller role metadata, and keep the dashboard explanatory only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-23 00:05:35 -04:00
jcwalker3andsysadmin caaae9b6ee feat(arch01): atomic platform install + authority kernel (Closes #822) (#839)
Co-authored-by: jcwalker3 <[email protected]>
2026-07-22 23:00:24 -05:00
sysadmin 689c60fc7c Merge pull request 'feat(webui): console authorization, RBAC, redaction, and audit model (Closes #633)' (#811) from feat/issue-633-console-authz-audit-model into master 2026-07-22 22:16:38 -05:00
jcwalker3 66a89a46bb Merge branch 'master' into feat/issue-633-console-authz-audit-model 2026-07-22 21:57:53 -05:00
sysadmin 53c2c92782 Merge pull request 'feat(webui): versioned project registry API (Closes #635)' (#819) from feat/issue-635-project-registry-api into master 2026-07-22 20:04:26 -05:00
sysadmin 14c9c4d702 Merge pull request 'fix(mcp): forward worktree_path into publication preflight (Closes #815)' (#817) from fix/issue-815-preflight-worktree-forwarding into master 2026-07-22 18:34:31 -05:00
jcwalker3andClaude Opus 4.8 95a5eb254f fix(mcp): forward worktree_path into publication preflight (Closes #815)
gitea_publish_unpublished_issue_branch accepted a required worktree_path
but resolved it only after verify_preflight_purity had already run, so the
#618 branches-only guard and every other workspace-resolution layer behind
preflight received None and fell back to the MCP process root. A daemon
rooted at the stable control checkout therefore refused a valid registered
issue worktree the caller had explicitly supplied, before the publication
assessor could use it — the sole verify_preflight_purity call site that
accepted a worktree argument and dropped it.

Resolve the workspace once, before preflight, and forward it. A blank or
absent path forwards None and keeps the ordinary #618 fail-closed fallback,
so guard strictness is unchanged for missing, empty, unregistered, foreign,
or control-checkout worktrees. Public tool contract, ownership, cleanliness,
hash, ancestry, and read-after-write protections from PR #814 are untouched.

Adds tests/test_issue_815_preflight_worktree_forwarding.py: a #735-style
capture proving the argument reaches verify_preflight_purity, a faithful
production reproduction (control-rooted daemon, no session lock, explicit
worktree) that clears #618 on the fixed source and is trapped at #618 on the
unpatched source, an end-to-end control-rooted publication in the real
topology (PROJECT_ROOT is the stable control checkout, the issue worktree is
a distinct registered path, production guards forced on), and negative
coverage keeping every #618 and assessor refusal intact. The prior #812
suite masked the defect by patching PROJECT_ROOT to equal the issue worktree.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 17:27:49 -05:00
sysadmin 910b6edbdc Merge pull request 'feat(mcp): publish an unpublished local commit on a registered issue worktree (Closes #812)' (#814) from feat/issue-812-publish-unpublished-commit into master 2026-07-22 16:40:54 -05:00
jcwalker3andClaude Opus 4.8 (1M context) &lt;[email protected]&gt; 99fda93bcc feat(mcp): publish an unpublished local commit on a registered issue worktree (Closes #812)
Entry point B of #812 is the state where an author's work has already advanced to a local commit: the worktree is registered, clean, on the issue branch, and carries the only copy of the implementation, but the branch has never been published. Two individually correct predicates close a cycle around it: exact-owner lease renewal refuses without an observable remote head, and every publication path is lock-derived under #618, so nothing can create that remote head without first holding the lock renewal would grant.

This adds the missing operation. gitea_publish_unpublished_issue_branch publishes an already-committed local head to its remote branch, so exact-owner renewal has the evidence its model requires. Publication is the whole of its authority: it renews, reclaims, rebinds, and clears nothing.

Why this is not a lock bypass: the operation can only publish a branch whose durable issue-lock record already names the caller as claimant. Ownership is read from the lock file, never asserted by the caller. Guard strictness is unchanged (AC15): a dirty tree, an untracked file the commit does not carry, an unregistered worktree, a non-issue or stable branch, a changed local HEAD, a remote head that is not an ancestor of the commit, a competing open PR for the same issue on another branch, and any declared-hash mismatch each fail closed. The refspec names the commit SHA explicitly and never forces.

Record separation (AC23): the durable issue-lock file and the control-plane workflow lease are distinct records. This reads the former as ownership evidence and writes neither.

Truthful process evidence (AC24): the recorded owner pid's liveness is never consulted or asserted. A regression pins the recorded pid to a live process, proves publication still succeeds, and proves expired-lock reclaim still refuses for that same pid.

Scope is AC20 only. AC21 cannot unblock on its own, so the two are separable and only the smaller one is implemented here.

Tests: 36 new cases against synthetic fixtures only, using a real git repository with a real local bare remote so publication and read-after-write verification are genuinely executed rather than mocked. AC17 is honoured and a regression asserts the protected worktree is never referenced.

Full suite: 11 failed, 4354 passed, 6 skipped, 533 subtests passed. The 11 failures are the documented pre-existing drift baseline at 9eb0f29, unchanged in count and identity.

Co-Authored-By: Claude Opus 4.8 (1M context) &lt;[email protected]&gt;
2026-07-22 16:18:00 -05:00
jcwalker3andClaude Opus 4.8 5494696227 feat(webui): read-only system-health API (Closes #634)
Adds `GET /api/v1/system/health`, a structured read-only health surface for
automated readiness checks, and keeps `/health` as the cheap liveness probe.

webui/system_health.py composes a DTO from fail-soft dependency probes: the
control-plane database, the local checkout, and — opt-in via `?deep=1` — live
Gitea reachability, each carrying status, reason, and probe latency. Required
probes drive readiness; the optional Gitea probe can only degrade overall
status, because local inventory stays serveable when the remote is
unreachable. A probe that did not run leaves readiness incomplete rather than
silently passing.

Read-only throughout: the control-plane database is opened through a `mode=ro`
URI because `ControlPlaneDB.__init__` creates directories and runs migrations,
which a health check must never do. No restart or reload control is exposed;
those are Phase 2 and #630 forbids process-kill recovery.

No unproven claims: `stale_runtime.mutation_safe` is true only when the
runtime, checkout, and remote commits are all known and equal, and MCP
namespaces always report `unproven` because a web process cannot exercise the
IDE-managed client path (#543). Probe details are redacted at the browser
boundary — URLs lose userinfo and query strings, credential-shaped text is
masked.

`/health` is expanded additively: every MVP key is retained, plus `started_at`,
`uptime_seconds`, and a pointer to the versioned API. The versioned route
returns 503 when not ready so automation can branch on the status code alone.

Verified at master 9eb0f29: focused file 40 passed / 11 subtests; `-k "webui or
health"` 230 passed / 159 subtests; full suite 4358 passed with the 11
pre-existing master-drift failures unchanged from the clean-master baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 15:59:30 -05:00
sysadminandClaude Opus 4.8 b2f6e9a6dc feat(webui): versioned project registry API (Closes #635)
Evolve the MVP project registry (#427) into a versioned, fail-closed
project registry API for the console (Phase 1, read-only).

- Add schema version 2 with project `status`, per-step onboarding
  `state`/`required`, optional redacted `last_seen_health`, and
  `remote_name`. Version 1 files stay loadable and are normalized with
  explicit defaults.
- Serve `/api/v1/projects` and `/api/v1/projects/{project_id}` with API
  provenance (`api_version`, `schema_version`, `source`). `/api/projects`
  is retained as an unversioned Phase 1 alias.
- Replace bare `ValueError` with `RegistryError`, carrying an operator
  `remediation` and `field_path`; invalid registries fail closed as a
  500 JSON payload or a dedicated HTML error page instead of a traceback.
- Reject credential-shaped keys before any DTO is built, reusing
  `registry_safety.is_forbidden_key` as the single source of truth
  shared with the worker registry (#798).
- Render HTML views from `project_to_dict`, so the console and the JSON
  API cannot disagree about status or onboarding progress.
- Document the contract in docs/webui-project-registry-api.md.

Tests: registry load/validate (valid, missing project, schema
validation, credential rejection) and API route coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 16:12:07 -04:00
jcwalker3andClaude Opus 4.8 479e434f92 feat(webui): console authorization, RBAC, redaction, and audit model (Closes #633)
Phase 1 of the MCP Control Plane Web Console (#631) defines the model that
future gated writes must pass through, and enables none of them.

The read-only MVP (#426-#436) ships with no authentication; protection comes
from network placement alone (#435). That is adequate while every route is a
GET and inadequate the moment Phase 2 wires a write. This lands the authority
first, so no write can later be added without something to check it against.

webui/console_authz.py
  Identity sources (none / local-dev / access-proxy), a four-role matrix
  (viewer, operator, controller, admin), the privileged-action list, and a
  fail-closed authorize(). Every action maps to a task_key in
  task_capability_map, so the console cannot invent an authority the MCP layer
  does not already define. Roles are always server-side configuration, never a
  client assertion. Deny reasons are closed and enumerated; there is no
  implicit allow branch, and even an allow reports execution_enabled=false
  while ACTIVE_PHASE is 1.

webui/console_redaction.py
  One redaction pass for API payloads, rendered HTML, logs, and audit records.
  Reuses gitea_audit.redact as the shared authority rather than forking it,
  then adds console patterns for keychain references, credential assignments,
  PEM private-key blocks, and JWTs. Never raises: an unredactable value
  degrades to the placeholder rather than being emitted raw.

webui/console_audit.py
  Console-side audit records, which gitea_audit cannot supply: it records MCP
  mutations and carries no console actor, identity source, correlation id, or
  retention class, and an authorization denial is not a mutation at all. The
  two are additive and join on correlation.request_id. Records are redacted at
  build time, re-scanned at write time, and dropped rather than persisted if
  they still trip a detector. Retention is per-record; an unknown action is
  retained as privileged rather than standard.

webui/app.py
  Attaches an authorization block to the existing preview and attempt routes
  and records the decision. The terminal outcome is unchanged - gated_actions
  still fails closed for every action - so this cannot loosen anything. Adds
  GET /api/console/security-model publishing the three policies as JSON.

Probe authentication is deliberately declarative in this slice:
probe_auth_required() reports operator intent and no route consults it. The
documentation says so plainly and a regression test pins the not-enforced
status, so wiring it in Phase 2 is a deliberate change rather than a silent
one. An operator who sets the variable believing it protects a probe would be
worse off than one who knows it does not.

Tests: tests/test_webui_console_authz_audit.py - 75 passed, 93 subtests,
covering each acceptance criterion and each test the issue requires
(redaction units, default-deny for unauthenticated write stubs, audit record
creation for a simulated privileged preview).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 14:37:35 -05:00
sysadmin 9eb0f29cef Merge pull request 'feat(webui): worker registry and configuration schema (Closes #798)' (#810) from feat/issue-798-worker-registry-schema into master 2026-07-22 06:48:29 -05:00
sysadmin 0b29404031 Merge pull request 'docs(webui): MCP Control Plane Web Console architecture ADR (Closes #632)' (#796) from docs/issue-632-web-console-architecture into master 2026-07-22 06:22:11 -05:00
jcwalker3andClaude Opus 4.8 5463f58933 feat(webui): worker registry and configuration schema (Closes #798)
Add the declarative worker registry that epic #797 makes the source of
truth for the scheduled multi-LLM worker fleet.

Providers and configured workers are modelled as separate entities so a
provider can be listed with no worker configured, and so provider facts
are not copied into every worker record. A worker records provider,
model, project, role, namespace, profile, workflow, schedule, timeout,
enabled state, and scheduler metadata.

Validation fails closed: unknown fields are refused rather than ignored,
so a typo cannot silently disable a timeout; a worker naming an
undeclared provider is rejected; worker ids, provider ids, and
LaunchAgent labels must be unique.

Persistence is atomic (temp file in the same directory, fsync, replace).
Every superseded document is retained as a numbered revision, and
rollback republishes a chosen revision as a new head, so history stays
append-only and a rollback is itself reversible.

The credential-rejection guard is extracted to webui/registry_safety.py
so both registries share one implementation instead of two copies of a
security check; project_registry.py keeps identical behaviour.

Scope: data model, validation, persistence only. No routes, scheduler,
process control, or provider probing - those are #799/#800/#804/#805.
The workers array ships empty because populating it is #808.

Tests: tests/test_webui_worker_registry.py, 44 cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 06:10:34 -05:00
sysadmin 5032965e3a Merge pull request 'feat: make master-parity live-remote aware so a stale daemon fails closed (Closes #610)' (#788) from feat/issue-610-live-remote-parity into master 2026-07-22 05:51:56 -05:00
jcwalker3andGrok 4.5 57a52b1a99 fix(parity): hermetic live-remote master reads under pytest (Closes #610)
Remediate PR #788 review F1/F2: suite-wide hermetic mode prevents
git ls-remote from running in tests so feature worktrees no longer flip
legacy runtime-context assertions to live_stale, and unit tests stay offline.
Module flag survives patch.dict(clear=True); env override still wins.

Co-Authored-By: Grok 4.5 <[email protected]>
2026-07-22 05:40:15 -05:00
jcwalker3andClaude Opus 4.8 e33b8d3712 docs(webui): add MCP Control Plane Web Console architecture ADR (Closes #632)
Adds the durable architecture record epic #631 lacked: layer contract,
authority boundaries, the redaction boundary, /api/v1 versioning with a
compatibility rule for the existing unversioned MVP exports, a target page
map, per-child component ownership for all twenty children (#632-#651),
phase gates, and explicit forbidden paths.

Documentation only. No UI, API, or deployment change.

- docs/architecture/webui-control-plane-console-architecture-adr.md (new)
- docs/webui-local-dev.md: cross-link to the ADR
- tests/test_webui_architecture_docs.py: doc gate for the acceptance
  criteria and the cross-link (12 cases)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 04:52:21 -05:00
jcwalker3 344dc41ce2 Merge branch 'master' into feat/issue-610-live-remote-parity 2026-07-22 04:30:44 -05:00
sysadminandClaude Opus 4.8 243f52dc79 feat(lease): make the author task heartbeat load-bearing (#790 Slice A)
Slice A of Issue #790, per the controller reassessment in comment 13958. Does
not close the issue: terminal retirement (Slice B) and the read-side generation
check plus the #760 renewal re-scope (Slice C) are deliberately not implemented.

The defect. `issue_lock_store.assess_lock_freshness` parsed `last_heartbeat_at`
and then never consulted it. Liveness was decided by an absolute four-hour
`expires_at` and by PID liveness, and the recorded PID is the long-lived MCP
daemon rather than the authoring task, so an abandoned claim stayed live for the
full four hours. A tree-wide search found the field written in exactly one place
and advanced by nothing. Issue #787 / PR #789 hit this; Issue #760 / PR #791 hit
it again, blocking reconciliation for over five hours after its work had landed.

A1 — central policy. New `lease_policy` declares every duration for every task
class in one place: author initial/sliding TTL 10 minutes, heartbeat cadence 2,
stale warning 5, missed-heartbeat grace 10, absolute cap 8 hours, recovery grace
10, terminal race-drain 2. It ships first so the first heartbeat and TTL
behavior to run reads from it (AC-N7). The duplicated four-hour literal is gone
from both `issue_lock_store` and `gitea_mcp_server`. Reviewer, merger, and
conflict-fix classes are declared but not rewired — Slice C moves those call
sites — and a test asserts the declaration still equals the constants #747 and
`pr_work_lease` own, so the two cannot drift apart unnoticed.

A2 — load-bearing freshness, with two deliberate asymmetries. An alive PID never
establishes freshness anywhere (AC-N2); it is recorded as evidence and no branch
returns live because of it. A dead PID still marks a lease stale, and that band
still precedes every heartbeat evaluation, so #753 dead-session recovery keys on
exactly the classification it always did. New bands `stale_missed_heartbeat` and
`stale_absolute_cap` are classified in `branch_cleanup_guard` rather than
falling through to unknown-status, and still block unless the ownership record
proves `reclaim_allowed is True`. A heartbeat lease carrying no heartbeat is
contradictory and fails closed. `assess_expired_lock_reclaim` accepts a lapsed
heartbeat as reclaim grounds for heartbeat-lifecycle leases only: under this
lifecycle the heartbeat is the liveness proof, and also requiring a dead PID
would reinstate the original defect.

A3/A4 — task-session identity and the writer. `mint_task_session_id` produces an
ownership key containing no process identifier, since the daemon PID is reused
by every task it serves and identifies none of them. `heartbeat_session_lock`
writes inside the existing per-issue flock under the #772 generation
compare-and-swap, verifying exact issue, branch, realpath-normalized worktree,
claimant username, claimant profile, and recorded session identifier. It cannot
acquire, take over, or revive: a lease past its grace is refused and must use
the reclaim path, so a session that stopped proving liveness cannot restore
ownership retroactively. New `gitea_heartbeat_issue_lock` gates on the same
authority as `lock_issue`, being strictly narrower.

A5 — legacy compatibility (AC-N8). The explicit `lifecycle_version` marker, never
a timestamp comparison, discriminates legacy from heartbeat leases: a legacy lock
has `last_heartbeat_at == created_at` forever precisely because nothing advanced
it, and a freshly minted heartbeat lease has them equal too, so the equality
carries no information in either direction. Legacy locks keep their recorded
absolute expiry and are never evaluated against the short grace, so deployment
cannot make an existing claim instantly reclaimable. They leave that state only
by terminal retirement (Slice B) or by `rebind_legacy_lock`, which re-verifies
the exact owner and mints a genuine identifier and first heartbeat while
preserving the original claim under `legacy_origin`. Rebinding a lapsed legacy
lease is refused; that belongs to #760 renewal or #601 reclaim.

A6 — native coverage. Review #499 proved assessor-level tests miss discard
points, so `tests/test_issue_790_heartbeat_mcp_path.py` drives the real tools
against a real git repository and a real durable lock: lock creation and
read-back, policy window, freshness, survival of `verify_lock_for_mutation`,
invariance of the duplicate-work and linked-open-PR gates, CAS rejection,
foreign-session and foreign-claimant refusal, alive-PID-only refusal, missed
heartbeat, legacy protection on deployment, and legacy rebinding.

Tests. New suites 55 passed. Lock and lease regression set (issue_lock_store,
lease_lifecycle, #753, #755, #760 x2, #768, #772, lock registration, worktree,
adoption, duplicate gate, branch cleanup guard, capability invariants, claim
heartbeat, worktrees) 383 passed with 98 subtests. Full suite 4295 passed, 11
failed, 6 skipped, 499 subtests passed, against a clean master baseline worktree
at 620ed6e9 that reports 11 failed and 4240 passed — the same eleven node IDs.
The 55-test delta is exactly the new suites; no new failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_011u6GKSJwwrrYjguPjs1aK5
2026-07-22 04:19:48 -04:00
sysadmin 620ed6e9a9 Merge pull request 'fix(lock): allow exact-owner renewal of expired author issue locks (Closes #760)' (#791) from fix/issue-760-exact-owner-renewal into master 2026-07-22 01:02:39 -05:00
sysadminandClaude Opus 4.8 a30a3ce4c3 fix(lock): make the exact-owner renewal waiver survive downstream gates (#760)
Addresses review #499 on PR #791.

F1 — the renewal sanction was computed and then discarded twice.

`assess_issue_lock_worktree` waived base-equivalence only for
`recovery_sanctioned`, and `gitea_lock_issue` never passed the renewal waiver
into it. A branch being renewed always carries committed work, so it is never
base-equivalent, and #753 recovery refuses when the recorded PID is alive —
which is the defining condition of a renewal. Every real renewal was therefore
granted by the assessor and then rejected one gate later.

Thread `renewal_sanctioned` into `assess_issue_lock_worktree` alongside
`recovery_sanctioned`, waiving base-equivalence on the same grounds and nothing
else. Cleanliness is evaluated before the waiver and is never relaxed; the
assessment now reports which of the two waivers applied.

The MCP-level regression then exposed a second discard point: the duplicate-work
gate rejected the renewal with "open PR already covers issue" — the very PR the
lock being renewed already owns. Add `owning_pr_renewal_evidence`, the mirror of
`issue_lock_recovery.owning_pr_recovery_evidence` (#755), and carry it into the
gate only when renewal was granted. It re-checks that the PR, local, and remote
heads agree, so truncated or hand-built evidence cannot authorize an exemption.

Neither waiver is caller-supplied and `gitea_lock_issue` still gains no
parameter. Absolute wall-clock expiry is unchanged. No #790 heartbeat, sliding
expiry, fencing-token, or shared-lifecycle behavior is introduced, and #753
recovery behavior is untouched.

F2 — add tests/test_issue_760_mcp_renewal_path.py, 10 cases driving the native
`gitea_lock_issue` path against a real git repository and a real durable lock:
expired lease, live recorded PID, committed non-base-equivalent branch. Proves
the renewal completes, records prior and replacement lease evidence, advances
the generation exactly once, produces a live lock that satisfies
`verify_lock_for_mutation`, and does not claim dead-session recovery. Negative
companions prove a foreign claimant, foreign profile, unpublished branch, or
mismatched PR head cannot use the waiver, that a dirty worktree still fails
closed, and that base-equivalence still applies with no waiver at all.

Tests: new MCP suite 10 passed; combined #760 suites 50 passed; lock/lease
regression set 200 passed with 2 subtests; full suite 4240 passed, 11 failed,
6 skipped, 493 subtests passed — the same 11 pre-existing failures as the
master baseline at 3d0c13fa, no new failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ti8deB36iWcjHmE9cuxour
2026-07-22 01:10:48 -04:00
sysadminandClaude Opus 4.8 1a97ced133 feat(lock): allow exact-owner renewal of expired author issue locks (Closes #760)
An expired author issue lease could not be renewed by the exact session that
already owned it. `assess_same_issue_lease_conflict` computed same-owner
evidence and then returned on the expired branch before consulting it, and
`assess_expired_lock_reclaim` only permits takeover on a dead PID or a missing
worktree. Because the recorded PID is the long-lived MCP daemon rather than the
authoring task, a lease that expires under a live daemon is the ordinary case
for any author task outliving the TTL — and in that case the owner's own lock
became permanently unmodifiable through sanctioned tools.

Add `issue_lock_renewal`, a pure evidence assessor for that one case, and
evaluate its disposition before the expired foreign-takeover return.

Renewal requires an exact match of remote, org, repo, issue number, operation
type, branch, realpath-normalized worktree, claimant username, and claimant
profile; a registered worktree that exists, sits on the locked branch, and is
clean; local and remote heads that agree; and, when an owning PR exists, a PR
head that agrees too. No competing live lock, competing branch claim, or other
owning PR may exist. Any missing or contradictory evidence fails closed.

PID liveness is never authorization: it is recorded as evidence and is neither
necessary nor sufficient (AC16). Only an expired lease is ever a candidate, so
a live foreign lease stays non-recoverable (AC12) and dead-PID takeover keeps
its existing #601 conditions (AC11). Renewal is never caller-declarable — the
waiver is server-computed and `gitea_lock_issue` gains no parameter (AC14).

A sanctioned renewal records prior PID, prior expiry, replacement PID, new
expiry, claimant, and its supporting proof under `lease_renewal`, and reuses
the #772 compare-and-swap so two sessions observing the same expired lease
cannot both win.

Absolute wall-clock expiry is preserved. Sliding heartbeat renewal, fencing
tokens, and the shared cross-role lifecycle remain #790's scope and are
deliberately not implemented here; #790 stays sequenced behind this change.

Tests: 40 new cases covering the positive path, every near-match and
foreign-owner refusal, the non-candidate cases, the gate-ordering regression,
the renewal record, downstream mutation ownership, and AC14/AC17. Two #772 test
doubles now forward the new keyword.

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

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

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

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

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

Tests: 35 new cases across parser, resolver, loader, and an MCP-level
gitea_allocate_next_work regression, including limit-invariant selection
over a 73-candidate inventory and a guard proving pre-ranking truncation
would change the winner. No issue number is special-cased.
2026-07-19 14:12:04 -04:00
sysadminandClaude Opus 4.8 324b4b3e93 feat: make master-parity live-remote aware so a stale daemon fails closed (Closes #610)
Master-parity previously compared only the daemon's startup commit against the
local on-disk HEAD. When the checkout was not pulled, parity reported green even
though the live remote master had advanced, so a stale daemon could claim a
mutation-safe result while running outdated capability gates (observed during
PR #592 recovery, where the resolver correctly required restart but parity said
in_parity=true).

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-09 18:52:59 -04:00
109 changed files with 35315 additions and 570 deletions
+150
View File
@@ -0,0 +1,150 @@
"""Canonical dependency parsing and live-state resolution for the allocator (#758).
The work allocator previously inferred dependency state from two lowercase
substrings (``"blocked on #"`` / ``"downstream of #"``). The repository's
canonical declaration form is a ``Depends:`` field inside the issue body's
linkage line, for example::
* Parent: #631 · Depends: #633, #634 · Related: #630, #434
That form matched neither substring, so dependency-blocked issues were emitted
as eligible candidates. This module replaces substring inference with:
1. structured parsing of ``Depends:`` declarations into issue references, and
2. resolution of each reference against **live issue state**, never body text.
Both halves fail closed: a reference whose state cannot be established makes
the owning candidate ineligible rather than assignable.
No issue number is special-cased here (#758 AC4/AC14); the parser is driven
entirely by the declaration syntax.
"""
from __future__ import annotations
import re
from typing import Callable, Iterable
# Live issue states, as reported by Gitea.
DEP_STATE_OPEN = "open"
DEP_STATE_CLOSED = "closed"
# "Depends:" / "Depends on:" introduces the declaration. "Dependencies" does
# not match: after "depend" it continues with "e", not "s".
_DEPENDS_KEYWORD = re.compile(r"depends(?:\s+on)?\s*:?\s*", re.IGNORECASE)
# Immediately after the keyword, consume only the contiguous run of issue
# references. Anchoring the run this way means the declaration ends naturally
# at the next separator ("·", newline) or sibling field ("Related:"), without
# needing to enumerate separators.
_DEP_RUN = re.compile(
r"\s*(#\d+(?:\s*(?:,|and|&)\s*#\d+)*)",
re.IGNORECASE,
)
# Legacy marker retained so previously-recognized bodies keep working.
_LEGACY_BLOCKED = re.compile(r"blocked\s+on\s+#(\d+)", re.IGNORECASE)
_ISSUE_REF = re.compile(r"#(\d+)")
def parse_dependency_refs(body: str | None) -> tuple[int, ...]:
"""Extract declared dependency issue numbers from an issue *body*.
Recognizes the canonical ``Depends: #N, #N`` field (including the
``Depends on`` spelling) plus the legacy ``blocked on #N`` marker.
Returns references in first-seen order with duplicates removed. Malformed
or absent declarations yield an empty tuple rather than raising.
"""
if not body:
return ()
refs: list[int] = []
def _add(value: str) -> None:
number = int(value)
if number > 0 and number not in refs:
refs.append(number)
for match in _DEPENDS_KEYWORD.finditer(body):
run = _DEP_RUN.match(body, match.end())
if not run:
continue
for ref in _ISSUE_REF.findall(run.group(1)):
_add(ref)
for ref in _LEGACY_BLOCKED.findall(body):
_add(ref)
return tuple(refs)
def resolve_dependency_state(
refs: Iterable[int],
state_lookup: Callable[[int], str | None],
*,
subject: str = "candidate",
) -> dict:
"""Resolve declared *refs* against live issue state.
*state_lookup* maps an issue number to its live state string, or to
``None`` when that evidence could not be obtained. A reference is:
* **met** when live state is ``closed``;
* **unmet** when live state is any other live value (``open``, etc.);
* **unavailable** when state is ``None`` or the lookup raises.
Unmet *and* unavailable both mark the candidate ineligible (#758 AC6/AC7):
allocation must never assume a dependency is satisfied.
"""
unmet: list[int] = []
unavailable: list[int] = []
met: list[int] = []
for ref in refs:
try:
number = int(ref)
except (TypeError, ValueError):
continue
try:
state = state_lookup(number)
except Exception: # noqa: BLE001 — unavailable evidence fails closed
state = None
normalized = (str(state).strip().lower() if state is not None else "") or None
if normalized is None:
unavailable.append(number)
elif normalized == DEP_STATE_CLOSED:
met.append(number)
else:
unmet.append(number)
reason: str | None = None
if unmet and unavailable:
reason = (
f"{subject} has unresolved dependencies "
f"{_fmt(unmet)} and unverifiable dependencies {_fmt(unavailable)} "
"(fail closed)"
)
elif unmet:
reason = (
f"{subject} depends on unresolved issue(s) {_fmt(unmet)}; "
"they are not closed"
)
elif unavailable:
reason = (
f"{subject} dependency evidence unavailable for {_fmt(unavailable)} "
"(fail closed)"
)
return {
"refs": tuple(int(x) for x in refs),
"met": tuple(met),
"unmet": tuple(unmet),
"unavailable": tuple(unavailable),
"dependency_unmet": bool(unmet or unavailable),
"reason": reason,
}
def _fmt(numbers: Iterable[int]) -> str:
return ", ".join(f"#{n}" for n in numbers)
+796 -51
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -77,6 +77,7 @@ MUTATION_TASKS = frozenset({
"create_issue",
"comment_issue",
"close_issue",
"edit_issue",
"mark_issue",
"lock_issue",
"set_issue_labels",
@@ -86,6 +87,7 @@ MUTATION_TASKS = frozenset({
"edit_pr",
"commit_files",
"gitea_commit_files",
"publish_unpublished_branch",
"delete_branch",
"cleanup_merged_pr_branch",
"cleanup_stale_claims",
+892
View File
@@ -0,0 +1,892 @@
"""ARCH-01 Foundation Slice A — atomic platform installation + authority kernel (#822).
Parents: #820, #821. **First implementation leaf of the ARCH-01 program.**
This module implements the smallest executable ARCH-01 foundation:
* a connection-bound authenticated actor context (``cp_actor_*`` /
``cp_operation_mode`` / ``cp_context_epoch`` SQLite scalar functions that SQL
may *read* but can never *set* — ``[TRUSTED-SERVICE]`` authenticity);
* an immutable authority-dominance lattice with an exact seeded tuple set
(``[SCHEMA]``);
* the principal-equivalence root (a class exists *before* its first principal;
``principals.current_class_id`` is ``NOT NULL``; ``[SCHEMA]``);
* a single-transaction platform installation that seeds the initial
``platform.bootstrap`` grant and an immutable ``installed`` marker, validated
by a fail-closed ``install_state`` ``BEFORE INSERT`` trigger (``[SCHEMA]``).
Everything else in the ARCH-01/02/04 program (evidence stores, repository
bindings, workspaces, PostgreSQL parity, full grant succession, full principal
merge) is out of scope here and tracked in its own issue — see #822 §5/§17.
**Readiness / production posture.** This subsystem is *disabled by default*.
Nothing in the running MCP server imports or enables it. It becomes a security
boundary only once its readiness checks (the ACs in #822) pass in the target
environment. Instantiating :class:`PlatformKernel` creates an isolated SQLite
database and never touches the operational control-plane store.
Enforcement classification (per #820 vocabulary):
* ``[TRUSTED-SERVICE]`` — actor-context authenticity: the scalar functions are
registered by the trusted Python process; SQL cannot define or redefine them.
* ``[SCHEMA]`` — fail-closed aborts, the dominance/immutability/NOT-NULL-class/
last-active-grant invariants, enforced by CHECK/FK/trigger.
* ``[RUNTIME-ADAPTER]`` — *none* in this slice.
SQLite-first. ``BEGIN IMMEDIATE`` serializes concurrent installs and concurrent
grant/revoke on the singleton invariant row. PostgreSQL parity is a distinct
issue (#827); this module does **not** claim it.
"""
from __future__ import annotations
import os
import sqlite3
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterator, Optional
# --------------------------------------------------------------------------- #
# Closed enumerations (#822 §4).
# --------------------------------------------------------------------------- #
ACTOR_KINDS = ("operator", "supervisor", "service", "installer")
OPERATION_MODES = ("normal", "install", "merge", "internal_service")
# Exact seeded authority-dominance tuple set (#822 §4). This set is normative:
# the install-state trigger rejects any missing, additional, or malformed tuple.
DOMINANCE_TUPLES = (
("platform.bootstrap", "platform.bootstrap"),
("platform.bootstrap", "project.admin"),
("platform.bootstrap", "supervisor.root.establish"),
("supervisor.root", "supervisor.register"),
("supervisor.root", "supervisor.verify"),
("supervisor.root", "supervisor.recover"),
)
# The distinguished operator-key issuer seeded during install.
DISTINGUISHED_ISSUER_KIND = "operator-key"
DISTINGUISHED_ISSUER_ID = "platform.bootstrap.operator-key"
# Structured result codes (#822 §10).
INSTALLED = "INSTALLED"
ALREADY_INSTALLED = "ALREADY_INSTALLED"
INVALID_ACTOR_CONTEXT = "INVALID_ACTOR_CONTEXT"
INVALID_BOOTSTRAP_STATE = "INVALID_BOOTSTRAP_STATE"
DOMINANCE_SET_MISMATCH = "DOMINANCE_SET_MISMATCH"
AUTHORIZATION_DENIED = "AUTHORIZATION_DENIED"
CONCURRENT_INSTALLATION_LOST = "CONCURRENT_INSTALLATION_LOST"
# Required audit events (#822 §14).
EVT_PLATFORM_INSTALLED = "platform_installed"
EVT_GRANT_CREATED = "platform_grant_created"
EVT_GRANT_REVOKED = "platform_grant_revoked"
EVT_PRINCIPAL_REGISTERED = "principal_registered"
SCHEMA_VERSION = 1
DB_PATH_ENV = "ARCH01_PLATFORM_DB"
class PlatformKernelError(RuntimeError):
"""Base class for structured, code-bearing kernel failures."""
def __init__(self, code: str, message: str = "") -> None:
super().__init__(message or code)
self.code = code
class ActorContextError(PlatformKernelError):
"""Raised when a mutation is attempted without a valid actor context."""
# --------------------------------------------------------------------------- #
# Schema (#822 §6). Tables + fail-closed triggers.
#
# Every *mutating* trigger opens with the actor protocol: read the context
# epoch, read the actor fields, and abort unless the context is present,
# non-null, mode/kind well-formed, and epoch-consistent with the active
# transaction. The scalar functions ``cp_*`` are registered from Python only;
# SQL has no statement that can set them, which is the trusted-service boundary.
# --------------------------------------------------------------------------- #
_ACTOR_KINDS_SQL = ", ".join("'%s'" % k for k in ACTOR_KINDS)
_OP_MODES_SQL = ", ".join("'%s'" % m for m in OPERATION_MODES)
# Actor-protocol predicate: TRUE when the context is INVALID and the trigger
# must abort. ``cp_actor_context_valid()`` folds "present + non-expired +
# live-epoch == bound-epoch" (the read/re-read epoch equality of #822 §4) into
# one trusted-service answer; the remaining reads assert field well-formedness.
_INVALID_ACTOR = (
"cp_actor_context_valid() IS NOT 1 "
"OR cp_context_epoch() IS NULL "
"OR cp_actor_principal() IS NULL "
"OR cp_actor_kind() NOT IN (%s) "
"OR cp_operation_mode() NOT IN (%s)" % (_ACTOR_KINDS_SQL, _OP_MODES_SQL)
)
_ACTOR_GUARD = (
"SELECT CASE WHEN (%s) "
"THEN RAISE(ABORT, 'INVALID_ACTOR_CONTEXT') END;" % _INVALID_ACTOR
)
# require_installed: abort a privileged mutation when there is no install
# marker and we are not currently installing (#822 §4).
_REQUIRE_INSTALLED = (
"SELECT CASE WHEN ((SELECT COUNT(*) FROM install_state) = 0 "
"AND cp_operation_mode() <> 'install') "
"THEN RAISE(ABORT, 'NOT_INSTALLED') END;"
)
_SCHEMA_SQL = f"""
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS arch01_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Equivalence classes are created BEFORE their first principal (#822 §4).
CREATE TABLE IF NOT EXISTS principal_equivalence_classes (
class_id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS authoritative_issuers (
issuer_id INTEGER PRIMARY KEY AUTOINCREMENT,
issuer_kind TEXT NOT NULL,
issuer_ref TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (issuer_kind, issuer_ref)
);
-- current_class_id is NOT NULL: a principal cannot exist without a class
-- (#822 AC6). issuer_id is nullable ONLY for the installer during install
-- (#822 AC7), enforced by trg_principals_null_issuer below.
CREATE TABLE IF NOT EXISTS principals (
principal_id TEXT PRIMARY KEY,
actor_kind TEXT NOT NULL CHECK (actor_kind IN ({_ACTOR_KINDS_SQL})),
current_class_id INTEGER NOT NULL REFERENCES principal_equivalence_classes(class_id),
issuer_id INTEGER REFERENCES authoritative_issuers(issuer_id),
registered_by TEXT REFERENCES principals(principal_id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS authority_dominance (
dominant TEXT NOT NULL,
subordinate TEXT NOT NULL,
PRIMARY KEY (dominant, subordinate)
);
CREATE TABLE IF NOT EXISTS platform_bootstrap_seed (
seed_id INTEGER PRIMARY KEY CHECK (seed_id = 1),
installer_principal_id TEXT NOT NULL REFERENCES principals(principal_id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS platform_bootstrap_grants (
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
grantee_principal_id TEXT NOT NULL REFERENCES principals(principal_id),
granted_by TEXT REFERENCES principals(principal_id),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at TEXT NOT NULL,
revoked_at TEXT
);
-- Singleton row; active_count floored at 1 by CHECK so the last active grant
-- can never be revoked (#822 AC11).
CREATE TABLE IF NOT EXISTS platform_active_invariant (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_count INTEGER NOT NULL CHECK (active_count >= 1)
);
-- The immutable install marker; inserted LAST in the install transaction.
CREATE TABLE IF NOT EXISTS install_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
marker TEXT NOT NULL CHECK (marker = 'installed'),
installed_at TEXT NOT NULL
);
-- Append-only (#822 AC14).
CREATE TABLE IF NOT EXISTS audit_records (
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
event TEXT NOT NULL,
principal_id TEXT,
detail TEXT,
created_at TEXT NOT NULL
);
-- ------------------------------------------------------------------------- --
-- Actor protocol on every mutating trigger (#822 §4, [SCHEMA] fail-closed).
-- ------------------------------------------------------------------------- --
CREATE TRIGGER IF NOT EXISTS trg_classes_actor
BEFORE INSERT ON principal_equivalence_classes
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_issuers_actor
BEFORE INSERT ON authoritative_issuers
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_principals_actor
BEFORE INSERT ON principals
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_dominance_actor
BEFORE INSERT ON authority_dominance
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_seed_actor
BEFORE INSERT ON platform_bootstrap_seed
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_grants_actor_insert
BEFORE INSERT ON platform_bootstrap_grants
BEGIN
{_ACTOR_GUARD}
{_REQUIRE_INSTALLED}
END;
CREATE TRIGGER IF NOT EXISTS trg_grants_actor_update
BEFORE UPDATE ON platform_bootstrap_grants
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_invariant_actor_insert
BEFORE INSERT ON platform_active_invariant
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_invariant_actor_update
BEFORE UPDATE ON platform_active_invariant
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_audit_actor
BEFORE INSERT ON audit_records
BEGIN
{_ACTOR_GUARD}
END;
-- ------------------------------------------------------------------------- --
-- NOT-NULL-issuer exception for the installer only (#822 AC7).
-- A NULL issuer_id is accepted solely for an installer principal during
-- install mode, before the marker exists; any other NULL-issuer principal is
-- rejected. install-time issuer linkage (installer -> distinguished issuer)
-- is applied by a later UPDATE, permitted while no marker exists.
-- ------------------------------------------------------------------------- --
CREATE TRIGGER IF NOT EXISTS trg_principals_null_issuer
BEFORE INSERT ON principals
WHEN NEW.issuer_id IS NULL
BEGIN
SELECT CASE WHEN NOT (
NEW.actor_kind = 'installer'
AND cp_operation_mode() = 'install'
AND (SELECT COUNT(*) FROM install_state) = 0
AND (SELECT COUNT(*) FROM principals WHERE issuer_id IS NULL) = 0
) THEN RAISE(ABORT, 'INVALID_BOOTSTRAP_STATE') END;
END;
-- ------------------------------------------------------------------------- --
-- Post-install immutability of the authority root (#822 §4, AC9).
-- Registration fields freeze only AFTER the marker exists, so the install
-- transaction's own installer issuer-linkage UPDATE is permitted.
-- ------------------------------------------------------------------------- --
CREATE TRIGGER IF NOT EXISTS trg_principals_frozen_update
BEFORE UPDATE ON principals
WHEN (SELECT COUNT(*) FROM install_state) > 0
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_PRINCIPAL');
END;
CREATE TRIGGER IF NOT EXISTS trg_principals_frozen_delete
BEFORE DELETE ON principals
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_PRINCIPAL');
END;
-- Distinguished issuer identity is immutable once written.
CREATE TRIGGER IF NOT EXISTS trg_issuers_immutable_update
BEFORE UPDATE ON authoritative_issuers
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_ISSUER');
END;
CREATE TRIGGER IF NOT EXISTS trg_issuers_immutable_delete
BEFORE DELETE ON authoritative_issuers
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_ISSUER');
END;
-- The dominance lattice is immutable once seeded.
CREATE TRIGGER IF NOT EXISTS trg_dominance_immutable_update
BEFORE UPDATE ON authority_dominance
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_DOMINANCE');
END;
CREATE TRIGGER IF NOT EXISTS trg_dominance_immutable_delete
BEFORE DELETE ON authority_dominance
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_DOMINANCE');
END;
-- The bootstrap seed is immutable once written.
CREATE TRIGGER IF NOT EXISTS trg_seed_immutable_update
BEFORE UPDATE ON platform_bootstrap_seed
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_SEED');
END;
CREATE TRIGGER IF NOT EXISTS trg_seed_immutable_delete
BEFORE DELETE ON platform_bootstrap_seed
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_SEED');
END;
-- The install marker is immutable once written.
CREATE TRIGGER IF NOT EXISTS trg_install_state_immutable_update
BEFORE UPDATE ON install_state
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_INSTALL_STATE');
END;
CREATE TRIGGER IF NOT EXISTS trg_install_state_immutable_delete
BEFORE DELETE ON install_state
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_INSTALL_STATE');
END;
-- Grants: identity is immutable; the ONLY permitted mutation is a single
-- active 1 -> 0 revocation (#822 §4 initial-grant identity immutability +
-- grant/revoke). Reactivation and identity edits are rejected.
CREATE TRIGGER IF NOT EXISTS trg_grants_identity_frozen
BEFORE UPDATE ON platform_bootstrap_grants
WHEN NOT (
NEW.grant_id = OLD.grant_id
AND NEW.grantee_principal_id = OLD.grantee_principal_id
AND NEW.granted_by IS OLD.granted_by
AND NEW.created_at = OLD.created_at
AND OLD.active = 1
AND NEW.active = 0
)
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_GRANT');
END;
CREATE TRIGGER IF NOT EXISTS trg_grants_no_delete
BEFORE DELETE ON platform_bootstrap_grants
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_GRANT');
END;
-- audit_records is append-only.
CREATE TRIGGER IF NOT EXISTS trg_audit_immutable_update
BEFORE UPDATE ON audit_records
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_AUDIT');
END;
CREATE TRIGGER IF NOT EXISTS trg_audit_immutable_delete
BEFORE DELETE ON audit_records
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_AUDIT');
END;
-- ------------------------------------------------------------------------- --
-- install_state BEFORE INSERT: validate the whole bootstrap atomically
-- (#822 §4, AC4). Each dominance tuple is checked individually; a missing,
-- additional, or malformed tuple -> DOMINANCE_SET_MISMATCH. The seed<->installer
-- link, the single active NULL-grantor installer grant, the installer's
-- non-NULL issuer, the active invariant, and "no extra principal created under
-- the NULL-issuer exception" -> INVALID_BOOTSTRAP_STATE.
-- ------------------------------------------------------------------------- --
CREATE TRIGGER IF NOT EXISTS trg_install_state_validate
BEFORE INSERT ON install_state
BEGIN
SELECT CASE WHEN NOT (
(SELECT COUNT(*) FROM authority_dominance) = {len(DOMINANCE_TUPLES)}
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='platform.bootstrap')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='project.admin')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='supervisor.root.establish')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.register')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.verify')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.recover')
) THEN RAISE(ABORT, 'DOMINANCE_SET_MISMATCH') END;
SELECT CASE WHEN NOT (
(SELECT COUNT(*) FROM platform_bootstrap_seed) = 1
AND (SELECT COUNT(*) FROM principals) = 1
AND (SELECT actor_kind FROM principals
WHERE principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
) = 'installer'
AND (SELECT issuer_id FROM principals
WHERE principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
) IS NOT NULL
AND (SELECT COUNT(*) FROM platform_bootstrap_grants
WHERE granted_by IS NULL AND active = 1
AND grantee_principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
) = 1
AND (SELECT COUNT(*) FROM platform_bootstrap_grants) = 1
AND (SELECT active_count FROM platform_active_invariant WHERE id = 1) = 1
) THEN RAISE(ABORT, 'INVALID_BOOTSTRAP_STATE') END;
END;
"""
def default_db_path() -> str:
return os.environ.get(
DB_PATH_ENV,
os.path.expanduser("~/.cache/gitea-tools/arch01/platform.sqlite3"),
)
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@dataclass(frozen=True)
class OperationResult:
"""Structured result of a kernel operation (#822 §10)."""
code: str
detail: str = ""
@property
def ok(self) -> bool:
return self.code in (INSTALLED, ALREADY_INSTALLED)
@dataclass
class _ActorContext:
principal: str
kind: str
mode: str
session: Optional[str]
bound_epoch: int
live_epoch: int
expired: bool = False
class PlatformKernel:
"""ARCH-01 authority kernel over a single SQLite connection.
The connection carries the trusted-service actor context: the ``cp_*``
scalar functions read the context this object holds. Only Python code here
can bind or clear it, so no SQL statement can assert an actor identity — the
trusted-service authenticity boundary of #822 §4.
"""
def __init__(self, db_path: Optional[str] = None, *, busy_timeout_ms: int = 5000) -> None:
self.db_path = db_path or default_db_path()
if self.db_path != ":memory:":
parent = os.path.dirname(self.db_path)
if parent:
os.makedirs(parent, exist_ok=True)
self._ctx: Optional[_ActorContext] = None
self._epoch_seq = 0
self._lock = threading.Lock()
# check_same_thread=False is safe: every mutation path is serialized
# by self._lock, so the connection is never used concurrently even when
# callers drive the kernel from different threads (concurrency tests).
self._conn = sqlite3.connect(
self.db_path, isolation_level=None, check_same_thread=False
)
self._conn.execute("PRAGMA foreign_keys = ON")
self._conn.execute(f"PRAGMA busy_timeout = {int(busy_timeout_ms)}")
self._register_actor_functions()
self._migrate()
# -- trusted-service actor functions ---------------------------------- #
def _register_actor_functions(self) -> None:
c = self._conn
c.create_function("cp_actor_principal", 0, lambda: self._ctx.principal if self._ctx else None)
c.create_function("cp_actor_kind", 0, lambda: self._ctx.kind if self._ctx else None)
c.create_function("cp_operation_mode", 0, lambda: self._ctx.mode if self._ctx else None)
c.create_function("cp_service_session", 0, lambda: self._ctx.session if self._ctx else None)
c.create_function("cp_context_epoch", 0, self._fn_context_epoch)
# Trusted-service helper: folds present + non-expired + epoch-consistent
# into the read/re-read epoch equality of #822 §4.
c.create_function("cp_actor_context_valid", 0, self._fn_context_valid)
def _fn_context_epoch(self) -> Optional[int]:
if self._ctx is None or self._ctx.expired:
return None
return self._ctx.live_epoch
def _fn_context_valid(self) -> int:
ctx = self._ctx
if ctx is None or ctx.expired:
return 0
# read/re-read epoch equality: a context whose live epoch has drifted
# from the epoch it was bound to (a stale/replaced connection context)
# is not bound to the active transaction and fails closed.
if ctx.live_epoch != ctx.bound_epoch:
return 0
if ctx.principal is None:
return 0
if ctx.kind not in ACTOR_KINDS or ctx.mode not in OPERATION_MODES:
return 0
return 1
# -- context lifecycle ------------------------------------------------ #
@contextmanager
def actor_context(
self, principal: str, kind: str, mode: str, session: Optional[str] = None
) -> Iterator[None]:
"""Bind a trusted actor context for the duration of the block."""
prev = self._ctx
self._epoch_seq += 1
epoch = self._epoch_seq
self._ctx = _ActorContext(
principal=principal, kind=kind, mode=mode, session=session,
bound_epoch=epoch, live_epoch=epoch,
)
try:
yield
finally:
self._ctx = prev
def _clear_context(self) -> None:
self._ctx = None
# -- migration -------------------------------------------------------- #
def _migrate(self) -> None:
self._conn.executescript(_SCHEMA_SQL)
self._conn.execute(
"INSERT OR IGNORE INTO arch01_meta(key, value) VALUES ('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
self._conn.execute(
"INSERT OR IGNORE INTO arch01_meta(key, value) VALUES "
"('architecture', 'ARCH-01 Slice A: atomic install + authority kernel (#822); "
"disabled by default until readiness checks pass')"
)
# -- introspection ---------------------------------------------------- #
def is_installed(self) -> bool:
row = self._conn.execute("SELECT COUNT(*) FROM install_state").fetchone()
return bool(row[0])
def active_grant_count(self) -> int:
row = self._conn.execute(
"SELECT active_count FROM platform_active_invariant WHERE id = 1"
).fetchone()
return int(row[0]) if row else 0
def audit_events(self) -> list[str]:
return [
r[0]
for r in self._conn.execute(
"SELECT event FROM audit_records ORDER BY audit_id"
).fetchall()
]
def close(self) -> None:
self._conn.close()
# -- operations ------------------------------------------------------- #
def install_platform(
self,
installer_principal_id: str = "platform.installer",
*,
session: Optional[str] = None,
) -> OperationResult:
"""Single atomic install transaction (#822 §4/§7).
``BEGIN IMMEDIATE`` serializes concurrent installs; the loser rechecks
the marker and returns ``ALREADY_INSTALLED``, or — if it never acquires
the write lock — ``CONCURRENT_INSTALLATION_LOST``. On any stage failure
the whole transaction rolls back leaving no partial rows (AC3/AC5).
"""
now = _utc_now_iso()
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
return OperationResult(CONCURRENT_INSTALLATION_LOST, str(exc))
raise
try:
if self.is_installed():
self._conn.execute("ROLLBACK")
return OperationResult(ALREADY_INSTALLED, "install marker already present")
with self.actor_context(installer_principal_id, "installer", "install", session):
c = self._conn
# class -> installer principal (temporary NULL issuer)
cur = c.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)",
(now,),
)
class_id = cur.lastrowid
c.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES (?, 'installer', ?, NULL, ?, ?)",
(installer_principal_id, class_id, installer_principal_id, now),
)
# distinguished operator-key issuer
cur = c.execute(
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) "
"VALUES (?, ?, ?)",
(DISTINGUISHED_ISSUER_KIND, DISTINGUISHED_ISSUER_ID, now),
)
issuer_id = cur.lastrowid
# link installer -> issuer (permitted pre-marker)
c.execute(
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
(issuer_id, installer_principal_id),
)
# dominance tuples
c.executemany(
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
DOMINANCE_TUPLES,
)
# seed
c.execute(
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) "
"VALUES (1, ?, ?)",
(installer_principal_id, now),
)
# initial grant (granted_by NULL, active)
c.execute(
"INSERT INTO platform_bootstrap_grants"
"(grantee_principal_id, granted_by, active, created_at) "
"VALUES (?, NULL, 1, ?)",
(installer_principal_id, now),
)
# active invariant
c.execute(
"INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)"
)
# audit rows for the security-sensitive operation
c.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_PRINCIPAL_REGISTERED, installer_principal_id, "installer", now),
)
c.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_GRANT_CREATED, installer_principal_id, "initial platform.bootstrap grant", now),
)
# install marker LAST -> fires the whole-bootstrap validator
c.execute(
"INSERT INTO install_state(id, marker, installed_at) VALUES (1, 'installed', ?)",
(now,),
)
c.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_PLATFORM_INSTALLED, installer_principal_id, "platform installed", now),
)
self._conn.execute("COMMIT")
return OperationResult(INSTALLED, "platform installed")
except sqlite3.Error as exc:
self._safe_rollback()
return OperationResult(self._classify(exc), str(exc))
def register_principal(
self,
principal_id: str,
actor_kind: str,
issuer_ref: str,
*,
actor_principal: str,
actor_kind_ctx: str = "operator",
session: Optional[str] = None,
) -> OperationResult:
"""Atomically create an equivalence class and its first principal.
The class is inserted *before* the principal, and ``current_class_id``
is ``NOT NULL`` (#822 AC6): a principal can never exist classless.
The principal references an existing issuer (non-NULL); the temporary
NULL-issuer exception is reserved for the installer during install
(AC7).
"""
if actor_kind not in ACTOR_KINDS:
return OperationResult(INVALID_BOOTSTRAP_STATE, f"bad actor_kind {actor_kind!r}")
now = _utc_now_iso()
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
return OperationResult(AUTHORIZATION_DENIED, str(exc))
try:
if not self.is_installed():
self._conn.execute("ROLLBACK")
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
row = self._conn.execute(
"SELECT issuer_id FROM authoritative_issuers WHERE issuer_ref = ?",
(issuer_ref,),
).fetchone()
if row is None:
self._conn.execute("ROLLBACK")
return OperationResult(INVALID_BOOTSTRAP_STATE, f"unknown issuer {issuer_ref!r}")
issuer_id = row[0]
with self.actor_context(actor_principal, actor_kind_ctx, "normal", session):
cur = self._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)",
(now,),
)
class_id = cur.lastrowid
self._conn.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(principal_id, actor_kind, class_id, issuer_id, actor_principal, now),
)
self._conn.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_PRINCIPAL_REGISTERED, principal_id, actor_kind, now),
)
self._conn.execute("COMMIT")
return OperationResult(INSTALLED, f"registered {principal_id}")
except sqlite3.Error as exc:
self._safe_rollback()
return OperationResult(self._classify(exc), str(exc))
def grant_platform_bootstrap(
self,
grantee_principal_id: str,
granted_by: str,
*,
actor_kind_ctx: str = "operator",
session: Optional[str] = None,
) -> OperationResult:
"""Create an additional active platform.bootstrap grant.
Serialized on the singleton invariant row via ``BEGIN IMMEDIATE``.
"""
now = _utc_now_iso()
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
return OperationResult(AUTHORIZATION_DENIED, str(exc))
try:
if not self.is_installed():
self._conn.execute("ROLLBACK")
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
with self.actor_context(granted_by, actor_kind_ctx, "normal", session):
self._conn.execute(
"INSERT INTO platform_bootstrap_grants"
"(grantee_principal_id, granted_by, active, created_at) "
"VALUES (?, ?, 1, ?)",
(grantee_principal_id, granted_by, now),
)
self._conn.execute(
"UPDATE platform_active_invariant SET active_count = active_count + 1 WHERE id = 1"
)
self._conn.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_GRANT_CREATED, grantee_principal_id, f"granted_by={granted_by}", now),
)
self._conn.execute("COMMIT")
return OperationResult(INSTALLED, f"granted to {grantee_principal_id}")
except sqlite3.Error as exc:
self._safe_rollback()
return OperationResult(self._classify(exc), str(exc))
def revoke_platform_bootstrap(
self,
grant_id: int,
*,
actor_principal: str,
actor_kind_ctx: str = "operator",
session: Optional[str] = None,
) -> OperationResult:
"""Revoke an active grant, floored so the last one can never drop.
The ``active_count >= 1`` CHECK plus ``BEGIN IMMEDIATE`` serialization
make two concurrent revocations unable to remove the final active grant
(#822 AC11): the decrement that would reach zero fails and rolls back.
"""
now = _utc_now_iso()
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
return OperationResult(AUTHORIZATION_DENIED, str(exc))
try:
if not self.is_installed():
self._conn.execute("ROLLBACK")
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
row = self._conn.execute(
"SELECT active, grantee_principal_id FROM platform_bootstrap_grants WHERE grant_id = ?",
(grant_id,),
).fetchone()
if row is None or row[0] != 1:
self._conn.execute("ROLLBACK")
return OperationResult(AUTHORIZATION_DENIED, "grant absent or already inactive")
grantee = row[1]
with self.actor_context(actor_principal, actor_kind_ctx, "normal", session):
# Decrement first: the CHECK floor rejects dropping below 1,
# aborting the whole revoke before the grant flips inactive.
self._conn.execute(
"UPDATE platform_active_invariant SET active_count = active_count - 1 WHERE id = 1"
)
self._conn.execute(
"UPDATE platform_bootstrap_grants SET active = 0, revoked_at = ? WHERE grant_id = ?",
(now, grant_id),
)
self._conn.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_GRANT_REVOKED, grantee, f"grant_id={grant_id}", now),
)
self._conn.execute("COMMIT")
return OperationResult(INSTALLED, f"revoked grant {grant_id}")
except sqlite3.Error as exc:
self._safe_rollback()
return OperationResult(self._classify(exc), str(exc))
# -- helpers ---------------------------------------------------------- #
def _safe_rollback(self) -> None:
try:
self._conn.execute("ROLLBACK")
except sqlite3.Error:
pass
@staticmethod
def _classify(exc: sqlite3.Error) -> str:
msg = str(exc)
if "INVALID_ACTOR_CONTEXT" in msg:
return INVALID_ACTOR_CONTEXT
if "DOMINANCE_SET_MISMATCH" in msg:
return DOMINANCE_SET_MISMATCH
if "active_count" in msg or "CHECK constraint failed: platform_active_invariant" in msg:
# last-active-grant floor tripped
return AUTHORIZATION_DENIED
if any(tag in msg for tag in (
"INVALID_BOOTSTRAP_STATE", "IMMUTABLE_", "NOT_INSTALLED",
)):
return INVALID_BOOTSTRAP_STATE
return INVALID_BOOTSTRAP_STATE
+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}"
)
+13 -1
View File
@@ -163,7 +163,19 @@ _TERMINAL_OWNERSHIP_STATUSES = frozenset(
{"released", "abandoned", "done", "blocked", "terminal", "closed"}
)
_EXPIRED_STATUSES = frozenset({"expired"})
_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"})
_STALE_STATUSES = frozenset(
{
"stale",
"stale_dead_process",
"stale_missing_worktree",
# #790 Slice A heartbeat-lifecycle bands. Listed here so they are
# *classified* rather than falling through to the unknown-status branch;
# they still block unless the ownership record proves
# ``reclaim_allowed is True``, so the O2 fail-closed rule is unchanged.
"stale_missed_heartbeat",
"stale_absolute_cap",
}
)
def _norm_str(value: Any) -> str:
+591
View File
@@ -0,0 +1,591 @@
"""Publish an unpublished local commit on a registered issue worktree (#812 AC20).
Entry point B of #812 is the state where an author's work has already advanced
to a local commit: the worktree is registered, clean, on the issue branch, and
carries the only copy of the implementation, but the branch has never been
published. That state deadlocks, because two individually correct predicates
close a cycle:
* ``issue_lock_renewal.assess_exact_owner_lease_renewal`` refuses to renew an
expired lease without an observable remote head — an unpublished branch has
none.
* Every publication path (``gitea_commit_files``, ``gitea_create_pr``) derives
its workspace from the author issue lock under #618, so nothing can create
that remote head without first holding the lock.
This module supplies the missing operation: it publishes an *already committed*
local head to the remote branch, so exact-owner renewal has the evidence it
requires. It deliberately does **not** renew, reclaim, rebind, or clear any
lock. Publication is the whole of its authority.
Why this is not a lock bypass
-----------------------------
The operation can only publish a branch whose **durable issue-lock record
already names the caller as claimant**. Ownership is read from the lock file on
disk (``issue_lock_store``), never from a caller-supplied flag, so the tool
cannot manufacture a claim it does not already hold. Nothing here weakens the
#510/#618/#713 guards: a dirty tree, an unregistered worktree, a foreign
claimant, a changed HEAD, or a divergent remote head each refuse, exactly as
they do today. The only thing this adds is the ability to make an existing,
owned, committed, clean branch observable on the remote.
Separation of records (#812 AC23)
---------------------------------
The durable **issue-lock file** and the control-plane **workflow lease** are
distinct records. This module reads the former as ownership evidence and writes
neither. Publishing changes remote git state only; no lock is renewed,
abandoned, reclaimed, or generation-bumped here.
Process evidence (#812 AC24)
----------------------------
Liveness of the lock's recorded pid is **not consulted**. That is deliberate:
the recorded pid routinely belongs to the long-running MCP daemon rather than to
an active author client, and the existing reclaim predicate
(``assess_expired_lock_reclaim``) can never be satisfied while that daemon runs.
Publication does not require the recording process to be dead, so this module
never asserts, infers, or depends on a process being dead. Ownership is proven
by identity and profile match against the recorded claimant instead.
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
from reviewer_worktree import parse_dirty_tracked_files
from stable_branch_push_guard import is_stable_ref, redact_command
# Assessment outcomes.
PUBLISH_SANCTIONED = "publish_sanctioned"
ALREADY_PUBLISHED = "already_published"
REFUSED = "refused"
#: Implementation branches must stay traceable to their issue (#713 lineage).
ISSUE_BRANCH_RE = re.compile(r"^(fix|feat|docs|chore)/issue-(\d+)-.+$")
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
def _text(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _realpath(value: str | None) -> str | None:
path = _text(value)
return os.path.realpath(path) if path else None
def parse_untracked_files(porcelain_status: str) -> list[str]:
"""Return untracked paths from ``git status --porcelain`` output.
``reviewer_worktree.parse_dirty_tracked_files`` deliberately skips ``??``
entries. Publication needs both halves: an untracked file in the worktree is
unpublished content that the commit does not carry, so publishing would
silently leave it behind.
"""
untracked: list[str] = []
for line in (porcelain_status or "").splitlines():
if not line.startswith("??"):
continue
path = line[2:].strip()
if path:
untracked.append(path)
return untracked
def hash_worktree_files(worktree_path: str, paths) -> dict[str, str | None]:
"""SHA-256 each path under *worktree_path*; ``None`` when unreadable."""
root = _text(worktree_path)
hashes: dict[str, str | None] = {}
for rel in paths or ():
rel_text = _text(rel)
if not rel_text:
continue
full = os.path.join(root, rel_text)
try:
with open(full, "rb") as handle:
digest = hashlib.sha256()
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
hashes[rel_text] = digest.hexdigest()
except OSError:
hashes[rel_text] = None
return hashes
def read_remote_branch_head(
worktree_path: str, remote_name: str, branch_name: str
) -> dict:
"""Observe the remote head for *branch_name*, read-only.
``probe_ok`` False means git could not answer at all. That is kept distinct
from "the branch does not exist": an unobservable remote must fail closed
rather than be mistaken for an absent branch, because the two lead to
opposite dispositions.
"""
path = _text(worktree_path)
remote = _text(remote_name)
branch = _text(branch_name)
result: dict = {
"probe_ok": False,
"remote_branch_exists": False,
"remote_head_sha": None,
"reasons": [],
}
if not (path and remote and branch):
result["reasons"].append(
"remote head probe requires a worktree path, remote name, and branch"
)
return result
try:
res = subprocess.run(
["git", "-C", path, "ls-remote", remote, f"refs/heads/{branch}"],
capture_output=True,
text=True,
check=False,
)
except OSError as exc: # git unavailable — fail closed, never assume absent
result["reasons"].append(f"remote head probe could not run: {exc}")
return result
if res.returncode != 0:
result["reasons"].append(
f"remote head probe failed for '{branch}' on remote '{remote}'"
)
return result
result["probe_ok"] = True
for line in (res.stdout or "").splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] == f"refs/heads/{branch}":
result["remote_branch_exists"] = True
result["remote_head_sha"] = parts[0].strip()
break
return result
def read_is_ancestor(
worktree_path: str, ancestor_sha: str, descendant_sha: str
) -> dict:
"""Observe whether *ancestor_sha* is an ancestor of *descendant_sha*."""
path = _text(worktree_path)
ancestor = _text(ancestor_sha)
descendant = _text(descendant_sha)
result: dict = {"probe_ok": False, "is_ancestor": False, "reasons": []}
if not (path and ancestor and descendant):
result["reasons"].append(
"ancestry probe requires a worktree path and both commit SHAs"
)
return result
try:
present = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", "--quiet",
f"{ancestor}^{{commit}}"],
capture_output=True, text=True, check=False,
)
if present.returncode != 0:
result["reasons"].append(
f"remote head {ancestor} is not present locally, so it cannot be "
"proven an ancestor of the commit being published"
)
return result
res = subprocess.run(
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
capture_output=True, text=True, check=False,
)
except OSError as exc:
result["reasons"].append(f"ancestry probe could not run: {exc}")
return result
result["probe_ok"] = res.returncode in (0, 1)
result["is_ancestor"] = res.returncode == 0
return result
def assess_unpublished_commit_publication(
existing_lock,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
expected_head: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
worktree_state,
worktree_registered: bool | None = None,
remote_probe=None,
ancestry=None,
competing_open_prs=(),
expected_file_hashes=None,
observed_file_hashes=None,
) -> dict:
"""Decide whether an unpublished local commit may be published (#812 AC20).
Pure predicate. Every input is either a caller-declared expectation that
must be *matched* against observation, or a server-side observation. No
caller-supplied boolean is accepted as proof of ownership, liveness, or
eligibility: ``existing_lock`` comes from the durable lock file and the
git/PR state is observed by the server.
The single mutating disposition it can return is "publish this exact commit
to this exact branch". It never sanctions renewal, reclamation, force
updates, history rewriting, or publication of uncommitted content.
"""
reasons: list[str] = []
branch = _text(branch_name)
head = _text(expected_head).lower()
workspace = _realpath(worktree_path)
state = worktree_state if isinstance(worktree_state, dict) else {}
lock = existing_lock if isinstance(existing_lock, dict) else None
evidence: dict = {
"issue_number": issue_number,
"branch_name": branch or None,
"worktree_path": workspace,
"expected_head": head or None,
"remote": _text(remote) or None,
"org": _text(org) or None,
"repo": _text(repo) or None,
"identity": _text(identity) or None,
"profile": _text(profile) or None,
"lock_record_present": lock is not None,
"recorded_claimant": None,
"recorded_branch": None,
"recorded_worktree": None,
"lock_generation": None,
"local_head_sha": _text(state.get("head_sha")) or None,
"current_branch": _text(state.get("current_branch")) or None,
"dirty_tracked_files": [],
"untracked_files": [],
"worktree_registered": worktree_registered,
"remote_branch_exists": None,
"remote_head_sha": None,
"fast_forward_from_remote": None,
"competing_open_prs": [],
"file_hashes_verified": None,
"hash_mismatches": [],
# Recorded explicitly so no reader mistakes silence for a liveness
# claim, and so the audit shows which records were left alone (AC23/AC24).
"owner_pid_liveness_consulted": False,
"workflow_lease_touched": False,
"issue_lock_record_mutated": False,
}
# ── declared shape ────────────────────────────────────────────────────
if not branch:
reasons.append("branch name not declared; fail closed")
if not head:
reasons.append(
"expected_head not declared; publication must name the exact commit"
)
elif not _SHA_RE.match(head):
reasons.append(
f"expected_head '{head}' is not a full 40-character commit SHA; "
"abbreviated or symbolic revisions are refused"
)
if not workspace:
reasons.append("worktree path not declared; fail closed")
if branch:
match = ISSUE_BRANCH_RE.match(branch)
if not match:
reasons.append(
f"branch '{branch}' is not an issue-linked implementation branch "
"((fix|feat|docs|chore)/issue-<number>-<description>); fail closed"
)
elif int(match.group(2)) != int(issue_number):
reasons.append(
f"branch '{branch}' does not carry issue number {issue_number}; "
"fail closed"
)
if is_stable_ref(branch):
reasons.append(
f"refusing to publish stable branch '{branch}'; this operation "
"publishes issue branches only"
)
# ── ownership: durable issue-lock record only (AC8, AC20, AC24) ───────
if lock is None:
reasons.append(
"no durable issue-lock record for this issue; publication requires an "
"existing recorded claim naming the caller, so this operation cannot "
"be used to bypass the author lock"
)
else:
lease = lock.get("work_lease")
lease = lease if isinstance(lease, dict) else {}
claimant = lease.get("claimant")
claimant = claimant if isinstance(claimant, dict) else {}
recorded_user = _text(claimant.get("username"))
recorded_profile = _text(claimant.get("profile"))
recorded_branch = _text(lock.get("branch_name")) or _text(lease.get("branch"))
recorded_worktree = _realpath(
_text(lock.get("worktree_path")) or _text(lease.get("worktree_path"))
)
evidence["recorded_claimant"] = {
"username": recorded_user or None,
"profile": recorded_profile or None,
}
evidence["recorded_branch"] = recorded_branch or None
evidence["recorded_worktree"] = recorded_worktree
try:
evidence["lock_generation"] = int(lock.get("lock_generation") or 0)
except (TypeError, ValueError):
evidence["lock_generation"] = 0
try:
recorded_issue = int(lock.get("issue_number") or 0)
except (TypeError, ValueError):
recorded_issue = 0
if recorded_issue != int(issue_number):
reasons.append(
f"durable lock records issue {lock.get('issue_number')}, not "
f"{issue_number}; ambiguous ownership, fail closed"
)
for field, declared in (
("remote", _text(remote)),
("org", _text(org)),
("repo", _text(repo)),
):
recorded = _text(lock.get(field))
if recorded and declared and recorded != declared:
reasons.append(
f"durable lock records {field} '{recorded}' but the request "
f"declares '{declared}'; repository mismatch, fail closed"
)
if recorded_branch and branch and recorded_branch != branch:
reasons.append(
f"durable lock records branch '{recorded_branch}' but the request "
f"declares '{branch}'; fail closed"
)
if recorded_worktree and workspace and recorded_worktree != workspace:
reasons.append(
f"durable lock records worktree '{recorded_worktree}' but the "
f"request declares '{workspace}'; fail closed"
)
if not recorded_user or not recorded_profile:
reasons.append(
"durable lock does not record a claimant username and profile; "
"ownership cannot be proven, fail closed"
)
else:
if recorded_user != _text(identity):
reasons.append(
f"durable lock claimant '{recorded_user}' is not the acting "
f"identity '{_text(identity) or '(unknown)'}'; foreign claim, "
"fail closed"
)
if recorded_profile != _text(profile):
reasons.append(
f"durable lock claimant profile '{recorded_profile}' is not "
f"the active profile '{_text(profile) or '(unknown)'}'; "
"fail closed"
)
# ── worktree: registered, on-branch, clean, at the expected commit ────
if worktree_registered is False:
reasons.append(
f"worktree '{workspace}' is not listed in git worktree list; #713 "
"requires a genuinely registered worktree, fail closed"
)
current_branch = _text(state.get("current_branch"))
if not current_branch:
reasons.append("worktree branch could not be observed; fail closed")
elif branch and current_branch != branch:
reasons.append(
f"worktree is on branch '{current_branch}', not '{branch}'; fail closed"
)
porcelain = state.get("porcelain_status") or ""
dirty_tracked = parse_dirty_tracked_files(porcelain)
untracked = parse_untracked_files(porcelain)
evidence["dirty_tracked_files"] = dirty_tracked
evidence["untracked_files"] = untracked
if dirty_tracked:
reasons.append(
"worktree has dirty tracked files, so the commit is not the whole of "
f"the work: {', '.join(dirty_tracked)}. This operation publishes an "
"existing clean commit only; uncommitted content is out of scope"
)
if untracked:
reasons.append(
"worktree has untracked files that the commit does not carry: "
f"{', '.join(untracked)}. Publishing would silently leave them "
"behind; fail closed"
)
local_head = _text(state.get("head_sha")).lower()
if not local_head:
reasons.append("local HEAD could not be observed; fail closed")
elif head and local_head != head:
reasons.append(
f"worktree HEAD is {local_head} but the request declares {head}; the "
"local commit changed since it was recorded, fail closed"
)
# ── remote state ──────────────────────────────────────────────────────
probe = remote_probe if isinstance(remote_probe, dict) else {}
already_published = False
if not probe.get("probe_ok"):
reasons.append(
"remote branch head could not be observed; publication must not "
"proceed against an unknown remote state, fail closed"
)
reasons.extend(probe.get("reasons") or [])
else:
remote_exists = bool(probe.get("remote_branch_exists"))
remote_head = _text(probe.get("remote_head_sha")).lower() or None
evidence["remote_branch_exists"] = remote_exists
evidence["remote_head_sha"] = remote_head
if remote_exists and remote_head and head:
if remote_head == head:
already_published = True
evidence["fast_forward_from_remote"] = True
else:
anc = ancestry if isinstance(ancestry, dict) else {}
is_anc = bool(anc.get("probe_ok")) and bool(anc.get("is_ancestor"))
evidence["fast_forward_from_remote"] = is_anc
if not is_anc:
reasons.append(
f"remote branch '{branch}' already exists at {remote_head}, "
f"which is not an ancestor of {head}; publishing would "
"discard or rewrite published history, fail closed"
)
reasons.extend(anc.get("reasons") or [])
elif remote_exists and not remote_head:
reasons.append(
f"remote branch '{branch}' exists but its head could not be read; "
"fail closed"
)
# ── competing claims ──────────────────────────────────────────────────
competing = [p for p in (competing_open_prs or ()) if p]
evidence["competing_open_prs"] = list(competing)
if competing:
reasons.append(
f"open pull request(s) {competing} already claim issue {issue_number} "
"or this branch; ambiguous ownership, fail closed"
)
# ── content verification before publication ───────────────────────────
if expected_file_hashes:
observed = (
observed_file_hashes if isinstance(observed_file_hashes, dict) else {}
)
mismatches: list[str] = []
for path, expected_digest in dict(expected_file_hashes).items():
actual = observed.get(path)
if actual is None:
mismatches.append(f"{path}: missing or unreadable in the worktree")
elif _text(actual).lower() != _text(expected_digest).lower():
mismatches.append(
f"{path}: expected {expected_digest}, observed {actual}"
)
evidence["hash_mismatches"] = mismatches
evidence["file_hashes_verified"] = not mismatches
if mismatches:
reasons.append(
"declared content hashes do not match the worktree: "
+ "; ".join(mismatches)
+ ". Refusing to publish content that is not what was recorded"
)
if reasons:
return {
"outcome": REFUSED,
"publish_sanctioned": False,
"already_published": False,
"reasons": reasons,
"evidence": evidence,
}
return {
"outcome": ALREADY_PUBLISHED if already_published else PUBLISH_SANCTIONED,
# Idempotent retry: a remote head that already equals the assessed commit
# needs no second push, so the caller verifies instead of acting.
"publish_sanctioned": not already_published,
"already_published": already_published,
"reasons": [],
"evidence": evidence,
}
def publish_commit_to_remote_branch(
*,
worktree_path: str,
remote_name: str,
branch_name: str,
expected_head: str,
) -> dict:
"""Send exactly *expected_head* to ``refs/heads/<branch_name>``.
The refspec names the commit SHA explicitly rather than ``HEAD`` or the
local branch, so what lands is the commit that was assessed and nothing
else. No force, no lease, no ``+`` prefix: a non-fast-forward is rejected by
git itself, the last of several independent guards against overwriting
published history.
"""
path = _text(worktree_path)
remote = _text(remote_name)
branch = _text(branch_name)
head = _text(expected_head)
result: dict = {
"success": False,
"pushed_ref": f"refs/heads/{branch}" if branch else None,
"pushed_sha": head or None,
"stderr": None,
"reasons": [],
}
if not (path and remote and branch and head):
result["reasons"].append(
"publication requires a worktree path, remote, branch, and commit SHA"
)
return result
refspec = f"{head}:refs/heads/{branch}"
try:
res = subprocess.run(
["git", "-C", path, "push", remote, refspec],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
result["reasons"].append(f"publication could not run: {exc}")
return result
if res.returncode != 0:
# Redact before surfacing: failures can echo credentialed remote URLs.
result["stderr"] = redact_command(res.stderr or "")
result["reasons"].append(
f"publication of {head} to '{branch}' on remote '{remote}' failed"
)
return result
result["success"] = True
return result
def verify_published_head(
*, worktree_path: str, remote_name: str, branch_name: str, expected_head: str
) -> dict:
"""Read-after-write: confirm the remote head equals *expected_head* (AC20)."""
probe = read_remote_branch_head(worktree_path, remote_name, branch_name)
head = _text(expected_head).lower()
observed = _text(probe.get("remote_head_sha")).lower() or None
verified = bool(head) and bool(probe.get("probe_ok")) and observed == head
reasons: list[str] = list(probe.get("reasons") or [])
if probe.get("probe_ok") and not verified:
reasons.append(
"read-after-write verification failed: remote head is "
f"{observed or '(absent)'}, expected {head}"
)
return {
"verified": verified,
"remote_head_sha": observed,
"expected_head": head or None,
"reasons": reasons,
}
+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.
@@ -0,0 +1,201 @@
# ADR: MCP Control Plane Web Console architecture and information architecture
- **Status:** Proposed (documentation only; blocks no code, gates every #631 child)
- **Date:** 2026-07-22
- **Tracking issue:** [#632](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/632) — architecture and information architecture (Phase 1)
- **Parent epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/631) — MCP Control Plane Web Console
- **Foundation (closed, extend — do not recreate):** #425 tracker and children #426 skeleton, #427 projects, #428 prompts, #429 queue, #430 runtime, #431 audit paste, #432 worktrees, #433 leases, #434 gated actions, #435 auth/deployment boundary, #436 tests/CI
- **Related:** `mcp-allocator-control-plane-observability-adr.md`, `mcp-stable-control-runtime-policy-adr.md`, `control-plane-db-substrate.md`, `../safety-model.md`, `../tool-boundaries.md`, `../credential-isolation.md`, `../webui-local-dev.md`, `../webui-deployment.md`
## 1. Context
The MVP web UI shipped under `webui/` as a read-only Starlette application with ten operator routes and a JSON export beside most of them. It is a working foundation, not the console product described by epic #631, and it carries no durable architecture record: no layer contract, no authority boundary, no API versioning rule, no page map, and no statement of which phase may open a write path.
Twenty children (#632#651) hang off #631. Without one architecture document each implementer re-derives boundaries, and the most likely failure is not a bad view — it is a privileged action wired into the browser before the authorization and audit model of #633 exists.
This ADR is the single retrievable design source for the console. It decides structure only. It implements no UI, no API, and no change to deployment topology.
## 2. Decision summary (core)
| Layer | Owns | Must not |
|-------|------|----------|
| **Browser UI** | Rendering, navigation, operator affordances | Hold tokens, call Gitea/providers directly, or execute an action the server did not gate |
| **HTTP route layer** (`webui/app.py`) | Versioned routing, authentication, authorization, redaction boundary, audit emission | Contain domain logic or reach past a loader to a raw credential |
| **Domain loaders** (`webui/*_loader.py`, `*_scanner.py`, `runtime_health.py`, `project_registry.py`) | Assembling read models from authoritative sources | Mutate anything, or emit unredacted secrets across the boundary |
| **Gitea** | Durable work record: issues, PRs, comments, reviews, labels, merges | Be the concurrency lock under multi-session load |
| **Control-plane DB** | Sessions, assignment, leases, heartbeats, events | Replace Gitea history |
| **MCP tools / capability gates** | Mutation authorization | Be re-implemented, mirrored, or bypassed by console code |
| **External providers** (Sentry/GlitchTip, AI providers) | Incident and usage data | Assign work or mutate Gitea outside the #612 bridge |
**One-liner:** **Gitea records. The DB coordinates. MCP tools authorize. The console projects state and executes only capability-checked, audited actions. Providers observe.**
## 3. Console surface today versus target
`webui/app.py` currently registers these routes (see `../webui-local-dev.md` for the operator-facing table): `/`, `/health`, `/queue`, `/projects`, `/projects/{id}`, `/prompts`, `/runtime`, `/audit`, `/worktrees`, `/leases`, `/actions`, and the unversioned exports `/api/queue`, `/api/projects`, `/api/prompts`, `/api/runtime`, `/api/audit`, `/api/worktrees`, `/api/leases`, `/api/actions`, `/api/actions/{id}/preview`, `/api/actions/{id}/attempt`.
Every one of these is **retained and evolved**. No child issue may recreate a route from scratch; each states in its PR which MVP surface it extends and what it changes.
## 4. Authority boundaries
### 4.1 Gitea (durable record)
Authoritative for issue and PR identity and state, comments, reviews and verdicts, labels, merges, and branch refs. When the console and Gitea disagree about durable state, Gitea wins and the console view is refreshed — never the reverse.
### 4.2 Control-plane DB (coordination)
Authoritative for live coordination: which session holds which assignment or lease, heartbeat freshness, expiry, and the allocation event log. The console reads it; only allocator and lease tools write it.
### 4.3 MCP capability gates (authorization)
`task_capability_map.py` and `gitea_resolve_task_capability` remain the only authority that decides whether a mutation may run. The console asks; it never answers. A console action that cannot name the MCP tool it delegates to is not an action — it is a defect.
### 4.4 Filesystem and git (local state)
Issue lock files, `branches/` worktrees, and registered git worktrees are read through existing scanners. The console never deletes, rebinds, or force-clears local state outside a Phase 2 gated action.
### 4.5 Providers (observe only)
Sentry/GlitchTip and AI providers are read surfaces. The #612 incident bridge is the only path that turns an observation into Gitea work.
## 5. Request flow and the redaction boundary
```text
browser ──HTTP──> route layer ──> domain loader ──> Gitea REST
│ ├──> control-plane DB
│ ├──> filesystem / git
│ └──> providers
[redaction boundary]
audit event
```
| Stage | May hold credentials | Emits |
|-------|----------------------|-------|
| Loader → route layer | yes (server-side, via `gitea_auth`) | domain objects |
| Route layer → browser | **no** | redacted DTOs, HTML |
Two invariants govern the boundary and are non-negotiable for every child:
1. **No secrets to the browser.** Tokens, keychain identifiers, Authorization headers, raw provider endpoints, and credential-bearing URLs are redacted by default, consistent with `../safety-model.md` §3 and `../credential-isolation.md`. Serializers redact; templates do not sanitize after the fact.
2. **No ungated mutations.** A write reaches an authoritative system only by delegating to an MCP tool that passed its own capability gate. HTML forms and JSON endpoints are transport, never authority.
## 6. API naming and versioning
**Decision:** all console APIs added from Phase 1 onward are served under `/api/v1/...`.
- Nouns are plural and hierarchical: `/api/v1/inventory/leases`, `/api/v1/system/health`.
- Read endpoints are `GET` and side-effect free.
- Phase 2 action endpoints are `POST /api/v1/actions/{action_id}/preview` and `POST /api/v1/actions/{action_id}/execute`; `preview` stays side-effect free and returns a mutation ledger.
- The existing unversioned MVP exports remain as **compatibility aliases** for the whole of Phase 1 so the current operator flow never breaks. They may be retired no earlier than Phase 2, and only after the replacing `v1` route ships and `../webui-local-dev.md` records the swap.
- A breaking change to a `v1` payload requires `/api/v2/...`, not an in-place edit.
- Every JSON payload carries enough provenance for an auditor to tell where the data came from — at minimum the source system and whether the inventory was complete, matching the pagination-proof habit the MVP queue export already established.
## 7. Page map
| Page | Purpose | Owning child | Evolves |
|------|---------|--------------|---------|
| `/` | Console shell, navigation, next-safe-action summary | #638 | MVP `/` (#426) |
| `/system` | System-health dashboard | #639 | new, backed by #634 |
| `/traffic` | Workflow traffic control, queues, blockers | #640 | MVP `/queue` (#429) |
| `/runtime` | Runtime and session view | #641 | MVP `/runtime` (#430) |
| `/projects`, `/projects/{id}` | Project registry and onboarding | #635 | MVP `/projects` (#427) |
| `/inventory` | Sessions, leases, locks, worktrees in one surface | #636 | MVP `/leases` (#433) + `/worktrees` (#432) |
| `/timeline` | Workflow events and conversation timeline | #637 | new |
| `/actions` | Gated action registry, preview, execution | #642, #643, #644 | MVP `/actions` (#434) |
| `/gitea` | Issue and PR linkage console | #645 | new |
| `/policy` | Guardrail visibility, then versioned editing | #646, #647 | new |
| `/notifications` | Human-attention routing | #648 | new |
| `/observability` | Sentry/GlitchTip correlation and durable issue creation | #649 | new |
| `/providers` | AI-provider connections and insights | #650 | new |
| `/analytics` | Usage, token cost, latency, workflow performance | #651 | new |
| `/audit` | Final-report validator preview and audit log | #431 foundation, extended by #633 | MVP `/audit` (#431) |
| `/prompts`, `/prompts/{id}` | Canonical prompt library | #638 | MVP `/prompts` (#428) |
| `/health` | Liveness and deployment metadata | #634 | MVP `/health` (#435) |
## 8. Component ownership for every epic child
Each #631 child maps to at least one architectural component defined above.
| Child | Capability area | Primary component | Phase |
|-------|-----------------|-------------------|-------|
| #632 | Architecture and information architecture | this ADR | 1 |
| #633 | Authorization, RBAC, secret redaction, audit and retention | route layer + redaction boundary (§5) | 1 |
| #634 | Read-only system-health API | `/api/v1/system/health` + health loader | 1 |
| #635 | Project registry API evolution | `/api/v1/projects` + `project_registry.py` | 1 |
| #636 | Session, lease, lock, worktree inventory API | `/api/v1/inventory/*` + `lease_loader.py`, `worktree_scanner.py` | 1 |
| #637 | Workflow-event and conversation timeline model | `/api/v1/events` + control-plane DB event log | 1 |
| #638 | Application shell evolution | browser UI layer + `layout.py` | 1 |
| #639 | System-health dashboard | `/system` page over #634 | 1 |
| #640 | Workflow traffic-control view | `/traffic` page over the queue loader | 1 |
| #641 | Runtime and session view | `/runtime` page over `runtime_health.py` | 1 |
| #642 | Sanctioned restart and graceful reload controls | gated action framework, restart class | 2 |
| #643 | Requests, intent preview, authorization, workflow initiation | `/api/v1/actions/*` execute path | 2 |
| #644 | Stale-runtime recovery, worktree rebinding, reconciliation controls | gated actions over filesystem/git authority | 2 |
| #645 | Gitea issue and PR linkage console | `/gitea` page over Gitea authority | 3 |
| #646 | Workflow policy and guardrail visibility | `/policy` read view over the capability map | 3 |
| #647 | Versioned policy editing, validation, simulation, approval, rollback | `/policy` write path, gated | 3 |
| #648 | Notifications and human-attention routing | notification component over the event model | 3 |
| #649 | Sentry/GlitchTip connections, correlation, durable issue creation | provider layer + #612 incident bridge | 4 |
| #650 | AI-provider connections and operational insights | provider layer | 4 |
| #651 | Model usage, token cost, latency, workflow analytics | analytics component over the event model | 4 |
Related but **outside** this epic: #667 (restart status, impact preview, and approval controls) belongs to the #655 restart-governance umbrella and must reuse the #642 action class rather than adding a second restart surface.
## 9. Phase gates
| Phase | May ship | Entry condition |
|-------|----------|-----------------|
| **1 — read-only visibility** | `GET` pages and `GET /api/v1/...` | this ADR accepted |
| **2 — controlled actions** | gated `POST` action execution | #633 authorization, RBAC, and audit model landed |
| **3 — orchestration and policy** | linkage, policy visibility, versioned policy editing | Phase 1 inventory plus the Phase 2 action framework |
| **4 — insights** | provider correlation, analytics | evidence-backed sources from Phases 13 |
Phase 1 must not open a mutation endpoint, and the read-only guard that returns `405 read-only-mvp` stays in force until the Phase 2 entry condition is met. A phase is not entered by exception; if a control is urgent, the entry condition is what gets prioritized.
## 10. Security and workflow safety
- **Fail closed** on unknown authentication, missing RBAC mapping, or ambiguous lease ownership. An unknown state renders as blocked, never as permitted.
- **Redact by default**, per §5.
- **Every privileged action** requires a resolved capability, an explicit operator confirmation, and a durable audit event naming actor, action, target, and outcome.
- **Contamination surfaces.** Session contamination — including a manually killed MCP daemon (#630) — must be shown and must block clean claims rather than being silently repaired.
- **Deployment boundary unchanged.** Loopback by default, with the existing refusal of public binds (#435). This ADR documents that target; it does not widen it.
## 11. Forbidden paths
These are rejected designs, not preferences:
1. **Raw provider incidents as work.** The allocator never receives an unclassified Sentry/GlitchTip incident; only the #612 bridge turns an observation into a Gitea issue.
2. **Browser-held tokens.** No credential, keychain identifier, or Authorization header is ever sent to the browser or embedded in a client bundle.
3. **Process-kill recovery.** The console must not expose `pkill`, process-identifier termination, or any host process kill as a recovery affordance (#630). Restart is the sanctioned, operator-owned path of #642 and the #655 umbrella.
4. **Ungated browser mutations.** No review, approval, merge, close, or comment may originate from the browser without passing an MCP capability gate.
5. **Policy invented in the console.** The console projects policy from the capability map and canonical workflows; it never encodes a second copy.
6. **Recreating MVP scope.** Re-implementing a #426#436 surface without an explicit evolve-or-extend statement is out of bounds.
## 12. Approval checklist (readable without chat history)
A controller can accept or reject this ADR against these six points alone:
1. Layers and their owners are defined (§2) and each authority is named (§4).
2. The redaction boundary and the two invariants are stated (§5).
3. API versioning is decided, including what happens to the existing unversioned routes (§6).
4. A page map exists and names an owning child for every page (§7).
5. Every #631 child maps to at least one component and one phase (§8).
6. Phase gates and forbidden paths are explicit (§9, §11).
## 13. Open questions and follow-ups
Unresolved choices are recorded here rather than settled by implication. Each needs its own durable issue before the phase that depends on it:
- **Authentication mechanism.** Whether the console authenticates via an access proxy (Cloudflare Access or equivalent) or an application-level session is deferred to #633. This ADR requires only that it fail closed.
- **Event model substrate.** Whether the #637 timeline reads the control-plane event log directly or through a projection is deferred to #637.
- **CI path filter coverage.** `webui/ci_paths.py` triggers the web UI suite on `webui/`, `tests/test_webui_*`, and `docs/webui*`. This ADR lives under `docs/architecture/`, so editing it alone does not trigger that gate; the accompanying `tests/test_webui_architecture_docs.py` does run in the full suite. Widening the filter is a small follow-up, deliberately not bundled into a documentation-only change.
- **Retention.** Audit-event retention duration is owned by #633.
## 14. Acceptance
Accepting this ADR means:
- Phase 1 children may proceed against the layers, page map, and API rules above.
- Phase 2 children may not open a write path until #633 lands.
- Any deviation is recorded as an amendment to this file with its own issue reference, not as an undocumented divergence in code.
+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.
+17
View File
@@ -40,6 +40,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
| Option | Description |
|--------|-------------|
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
| Workflow dashboard (queue, leases, next safe action) | Documents the read-only `gitea_workflow_dashboard` MCP tool (#605): live PR/issue queues, leases by role, terminal review lock, blocked items, and exact next-safe prompts. **Does not assign work** — assignment still uses `gitea_allocate_next_work`. Never presents blocked/terminal-locked items as safe. The shell entry is documentation only (no Gitea mutation). |
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
@@ -50,6 +51,22 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
| Exit | Quit the menu. |
### Workflow dashboard MCP tool (#605)
From any healthy Gitea MCP namespace with `gitea.read`:
```text
gitea_workflow_dashboard(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
```
Response includes `human_summary` plus structured queues, `active_leases_by_role`,
`terminal_review_lock`, `blocked_items`, `next_safe_by_role`, and
`primary_next_safe_action`. Incomplete inventory fails closed.
## Placeholder-only entries
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
+41
View File
@@ -110,8 +110,49 @@ healthy. See `docs/mcp-namespace-health.md`.
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
reconnect.
## Sanctioned recovery vs forbidden process manipulation (#630)
Both restore a working namespace. Only one leaves the session trustworthy.
**Sanctioned — the runtime is repaired by whoever owns it:**
- IDE/host auto-reconnect, or an explicit client reconnect (`/mcp reconnect`).
- Relaunching the IDE/client so it respawns the daemons it started.
- An operator-owned restart performed outside the workflow session.
**Forbidden — the session manipulates the processes its own proof depends on:**
- `pkill -f mcp_server.py`, `pkill -f gitea_mcp_server`, broad `pkill -f mcp`.
- `killall` of a daemon, or `kill <pid>` of an MCP daemon pid.
- Any pattern broad enough to take unrelated namespaces with it
(`pkill -f python`), even when it never names MCP.
Read-only inspection (`ps aux | grep mcp_server`) is neither: it proves nothing
and breaks nothing. A `kill` of some unrelated pid is reported as *ambiguous*
rather than contaminating, so ordinary subprocess work is never false-blocked.
**What happens on a detected attempt.** `gitea_record_daemon_process_kill_attempt`
classifies a proposed command and, when it is a manual daemon kill, writes a
durable contamination marker for the active profile identity. While that marker
is live every review / merge / close / completion mutation fails closed;
`comment_issue` and `lock_issue` stay allowed so the contaminated worker can
still post its audit comment and hand off. The final report must surface the
contaminated recovery and must not claim a clean session.
Contamination is **not self-clearable**. Only
`gitea_audit_runtime_recovery_contamination` with `action=clear`, run under a
reconciler profile, removes it. The marker is recovery-critical, so it does not
expire into cleanliness when the session-state TTL lapses.
**Operator-authorized host maintenance stays permitted.** Authorization is read
from the `GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION` environment variable
and from nowhere else — set outside the session by the operator who owns the
host, and recorded as an audit reference on the assessment. It is deliberately
not a tool argument: a session must never be able to authorize itself.
## Related
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
- `docs/mcp-client-registration.md` — per-server registration contract.
+174
View File
@@ -0,0 +1,174 @@
# 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_issue_lock`
- `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_publish_unpublished_issue_branch`
- `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)
+295
View File
@@ -0,0 +1,295 @@
# Web console authorization, RBAC, redaction, and audit model (#633)
**Phase 1. Read-only. This document defines the model that future console
writes must pass through; it enables none of them.**
The MVP deployment boundary ([`webui-deployment.md`](webui-deployment.md), #435)
documents internal-only serving and states plainly that MVP authentication is
*none* — protection comes from network placement. That is adequate while every
route is a GET, and inadequate the moment a gated write ships. This document
and the three modules it describes land **before** any write exists, so no
Phase 2 action can be added without an authority to check it against.
| Concern | Module |
|---------|--------|
| Identity, roles, authorization decision | `webui/console_authz.py` |
| Secret redaction for every surface | `webui/console_redaction.py` |
| Audit event schema, retention, sink | `webui/console_audit.py` |
| Machine-readable publication | `GET /api/console/security-model` |
Two invariants hold everywhere and are non-negotiable for every child of #631:
1. **No secrets reach the browser.** Credentials are resolved server-side and
redacted before any payload, page, log line, or audit record leaves.
2. **No ungated mutations.** Authorization is necessary but never sufficient;
execution stays disabled until the Phase 2 framework ships.
## Identity sources
The console performs *authorization*. Authentication is delegated, because a
console that mints its own sessions is a credential store, and this one must
not be.
| Source | Mode value | Authenticated | Shared host | Phase |
|--------|-----------|---------------|-------------|-------|
| None | `none` (default) | No — anonymous, capped at `viewer` | No | 1 |
| Local dev | `local-dev` / `local_dev` | Yes, **asserted not verified** | No | 1 |
| Access proxy | `access-proxy` / `access_proxy` | Yes, asserted by trusted proxy | Yes | 2 |
Selected by `WEBUI_AUTH_MODE`. An unrecognised value falls back to `none`
rather than erroring open.
**Access-proxy mode** reads the subject from the
`Cf-Access-Authenticated-User-Email` header, set by Cloudflare Access, WARP, or
an equivalent org portal that terminates authentication in front of the
console. If the header is absent the request did not traverse the proxy, so the
principal degrades to anonymous — it is never trusted by default.
The **role is always server-side configuration**, never a client assertion. It
comes from `WEBUI_ROLE_MAP`, a JSON object of subject → role:
```json
{"[email protected]": "operator", "[email protected]": "controller"}
```
An unmapped subject gets `viewer`. Malformed JSON yields an empty map, so
everyone gets `viewer` — a parse failure loses authority rather than granting
it.
Full SSO is explicitly a non-goal of this issue.
## Role matrix
Four roles, ordered least to most authority. Each role inherits every lower
role's actions; the table states the *minimum* rank required.
| Role | Authority |
|------|-----------|
| `viewer` | Read every console view. No write, ever, in any phase. |
| `operator` | Viewer, plus author-class work: claim, comment, open a PR. |
| `controller` | Operator, plus reviewer/merger-class decisions on a PR. |
| `admin` | Controller, plus destructive and policy-editing actions. |
`viewer` holds the empty write set by construction, and a test asserts it stays
empty.
## Privileged actions
Every console action maps to a `task_key` in `task_capability_map.py`, the same
single source of truth `gitea_resolve_task_capability` and the MCP tool gates
use. The console therefore cannot invent an authority the MCP layer does not
already define, and a regression test asserts each mapping matches.
| Action | Minimum role | Class | MCP permission | Confirm | Dual control | Break-glass | Phase |
|--------|--------------|-------|----------------|---------|--------------|-------------|-------|
| `claim_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
| `comment_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
| `create_issue` | operator | gated_write | `gitea.issue.create` | Yes | No | No | 2 |
| `comment_pr` | operator | gated_write | `gitea.pr.comment` | Yes | No | No | 2 |
| `create_pr` | operator | gated_write | `gitea.pr.create` | Yes | No | No | 2 |
| `review_pr` | controller | privileged | `gitea.pr.review` | Yes | No | No | 3 |
| `close_pr` | controller | privileged | `gitea.pr.close` | Yes | No | No | 3 |
| `merge_pr` | controller | privileged | `gitea.pr.merge` | Yes | **Yes** | **Yes** | 3 |
| `delete_branch` | admin | destructive | `gitea.branch.delete` | Yes | **Yes** | **Yes** | 3 |
**Dual control** means the acting principal may not be the sole authority: a
second distinct principal must confirm. **Break-glass** means the action is
expected to be unavailable in normal operation and its use is retained for two
years. Both are declared here and enforced by the Phase 2 framework; Phase 1
records the requirement on every decision so the framework cannot ship without
honouring it.
`delete_branch` is admin-only rather than controller because it is the one
irreversible action in the set.
### Authorization decision
`authorize(action_id, principal, for_execution=False)` returns a decision
record and **denies by default**. The deny reasons are closed and enumerated:
| Reason code | Meaning |
|-------------|---------|
| `unknown_action` | No such console action is registered. |
| `unauthenticated` | The principal is anonymous. |
| `unknown_role` | The role is not in the matrix. |
| `insufficient_role` | The role ranks below the action's minimum. |
| `phase_not_active` | Execution requested for an action whose phase is not open. |
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
There is no implicit allow branch. Even the allow result reports
`execution_enabled: false` while the console is in Phase 1, so no caller can
read an allow as permission to mutate.
## Secret redaction
One pass applies to **API payloads, rendered HTML, server logs, and audit
records** — the four surfaces where a credential could escape.
Redaction reuses `gitea_audit.redact` rather than forking it: that remains the
authority for secret-looking dict keys, `Authorization` material, and raw URLs.
The console layer then applies its own patterns:
Each rule below matches an *assignment form*: the named key, followed by `=` or
`:`, followed by the value. The keys are listed bare rather than spelled out as
complete assignments, because this document is itself scanned by
`scan_for_secrets` — writing the examples in full assignment form would make the
documentation trip the very detectors it documents.
| Rule | Catches (as an assignment) |
|------|----------------------------|
| `credential_assignment` | `token`, `password`, `passwd`, `secret`, `api_key`, `access_key`, `client_secret`, `private_key` |
| `credential_env_assignment` | `GITEA_TOKEN`, `GITEA_PASS`, `GITEA_PASSWORD` and suffixed variants |
| `keychain_reference` | `keychain:` entry references |
| `keychain_command` | macOS `security` keychain lookups (`find-generic-password`, `find-internet-password`) |
| `private_key_block` | PEM `BEGIN ... PRIVATE KEY` blocks |
| `json_web_token` | Three-segment `eyJ...` JWTs |
| `bearer_credential` | `Bearer` / `Basic` credentials |
Assignments keep the key and replace only the value, so an operator can still
see *what* was removed. Two behaviours are deliberate:
- **Fail closed.** A value that cannot be redacted becomes `[REDACTED]`
outright rather than being emitted raw. Redaction never raises.
- **Redact before persist.** `console_audit.build_event` redacts before
serialization, and `write_event` re-scans and **drops** any record that still
trips a detector. An unredacted record is never durable.
`scan_for_secrets` is the assertion helper: it returns the detector names still
matching a payload, and already-redacted hits are not findings. Tests use it to
prove the published policy, the security-model endpoint, and this document
itself carry no secret material.
## Audit event schema
`gitea_audit` records MCP-side *mutations* — which profile and Gitea user
performed which tool call. It has no console actor, no identity source, no
correlation identifier, and no retention class, and an authorization **denial**
is not a mutation, so it would never appear there at all. The console record is
additive, not a replacement: a Phase 2 action emits both, joined on
`correlation.request_id`.
Required fields, all asserted by tests so an edit cannot quietly drop one:
| Field | Content |
|-------|---------|
| `schema_version` | Currently `1`. |
| `event_id` | Unique per record. |
| `timestamp` | Timezone-aware ISO-8601, UTC. |
| `actor` | `subject`, `role`, `identity_source`, `authenticated`. |
| `action` | Console action id. |
| `action_class` | `gated_write`, `privileged`, `destructive`, or `unknown`. |
| `target` | `{kind, ref}`, e.g. `{"kind": "pr", "ref": "#123"}`. |
| `result` | `allowed`, `denied`, `previewed`, `failed`, `succeeded`. |
| `reason_code` | The authorization reason code above. |
| `correlation` | `request_id`, `session_id`, `mcp_task`, `mcp_permission`. |
| `retention` | `class`, `days`, `expires_at`. |
| `redacted` | Always `true`; records are redacted at build time. |
An unrecognised `result` degrades to `failed` rather than being stored
verbatim.
The sink is an append-only JSON Lines file named by
`WEBUI_CONSOLE_AUDIT_LOG`. It is **off by default**: with the variable unset,
events are still built — so callers and tests exercise the schema — but nothing
is written. Auditing never raises; a failed write returns `False` rather than
breaking the request it describes.
## Retention
| Class | Applies to | Default |
|-------|-----------|---------|
| `standard` | Routine gated writes | 90 days |
| `privileged` | `review_pr`, `close_pr`, and any unclassifiable action | 365 days |
| `break_glass` | `merge_pr`, `delete_branch` | 730 days |
Each record carries its own class, day count, and computed `expires_at`, so
retention is auditable per record rather than inferred from file age. An
**unknown action is retained as privileged, not standard** — for a safety
control the conservative direction is to keep the record longer.
Nothing in this module updates or deletes. Expiry is enforced by an
operator-run policy against `expires_at`, never by the console silently
rewriting its own history.
## Phase 2 integration
Phase 2 opens gated writes. It must reuse this model rather than introduce a
second one. The integration points are already wired and observable:
- **`GET /api/actions/{action_id}/preview`** attaches an `authorization` block
to the existing preview payload and records a `previewed` audit event.
- **`POST /api/actions/{action_id}/attempt`** attaches the same block and
records a `denied` event. The terminal outcome is unchanged — the MVP
registry in `webui/gated_actions.py` still fails closed for every action — so
Phase 1 cannot loosen anything. Phase 2 enforces on this same decision
instead of adding a parallel check.
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
policy, and audit policy as JSON for operators and tests.
To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the
confirmation and dual-control flow the matrix already declares, emit a
`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and
keep `viewer` unable to reach any of it. Turning on execution without the
confirmation flow contradicts a declared requirement and is a review failure,
not a shortcut.
## Local-dev mode
`WEBUI_AUTH_MODE=local-dev` reads the principal straight from the environment:
| Variable | Purpose |
|----------|---------|
| `WEBUI_DEV_SUBJECT` | Subject string; absent ⇒ anonymous |
| `WEBUI_DEV_ROLE` | One of `viewer`, `operator`, `controller`, `admin`; unrecognised ⇒ `viewer` |
**INSECURE — this mode is for loopback development only.** The subject and role
are *asserted by the developer running the process and verified by nothing*.
Anyone able to set an environment variable on the host is an `admin`, and
anyone able to reach the port inherits that principal. It provides no
authentication whatsoever; it exists so Phase 2 authorization paths can be
exercised without standing up a proxy.
Never enable local-dev mode on a non-loopback bind. Combining it with
`WEBUI_ALLOW_PUBLIC_BIND=1` or `WEBUI_ALLOW_REMOTE_BIND=1` publishes an
unauthenticated admin console.
For anything beyond a laptop use `access-proxy` mode behind Cloudflare Access,
WARP, or a VPN, as [`webui-deployment.md`](webui-deployment.md) requires.
### Probe authentication
`WEBUI_REQUIRE_PROBE_AUTH=1` declares that non-public probes should require an
authenticated principal. It is **opt-in**: the default is off so the MVP
`/health` contract is unchanged.
**This flag is declarative in Phase 1 and enforces nothing today.**
`console_authz.probe_auth_required()` reports the operator's intent, and no
route consults it — setting the variable does not currently change the
behaviour of `/health` or any other endpoint. It is published here so the Phase
2 action framework has a declared policy to honour rather than inventing a
second one, exactly as `ACTIVE_PHASE` gates execution while the matrix is
already declared. A regression test pins this "declared, not enforced" status,
so wiring it later is a deliberate change rather than a silent one.
Until Phase 2 wires it, probe protection rests on network placement alone, as
[`webui-deployment.md`](webui-deployment.md) (#435) states.
## Environment variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `WEBUI_AUTH_MODE` | `none` | Identity source selection |
| `WEBUI_DEV_SUBJECT` | unset | Local-dev subject (insecure) |
| `WEBUI_DEV_ROLE` | `viewer` | Local-dev role (insecure) |
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
All are read server-side only. None is ever rendered into a page or returned by
an API.
## Non-goals
- No full SSO product; authentication stays delegated to the proxy.
- No browser-initiated merges or approvals in any phase covered here.
- No tokens in the frontend, in browser storage, or in committed config.
+4 -1
View File
@@ -7,7 +7,10 @@ only.
## MVP deployment model
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
- **Authentication:** none in MVP — protection comes from network placement
- **Authentication:** none in MVP — protection comes from network placement.
The authorization, RBAC, redaction, and audit model that future gated writes
must pass through is defined in
[`webui-authz-audit.md`](webui-authz-audit.md) (#633).
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
embedded in HTML, JavaScript, or browser storage
+95 -3
View File
@@ -37,17 +37,30 @@ Optional environment variables:
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
See
[architecture/webui-control-plane-console-architecture-adr.md](architecture/webui-control-plane-console-architecture-adr.md)
for the console architecture: layer and authority boundaries, the redaction
boundary, `/api/v1/...` versioning, the target page map, and the phase gates
that govern when a write path may open (#632, epic #631).
See [webui-project-registry-api.md](webui-project-registry-api.md) for the
versioned project registry contract: registry schema versions 1 and 2, project
status, onboarding checklist state, and the fail-closed error payloads (#635).
## Routes (MVP)
| Path | Description |
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`, `uptime_seconds`) |
| `/api/v1/system/health` | Structured read-only system health (#634) |
| `/queue` | Live PR and issue queue dashboard (#429) |
| `/api/queue` | JSON queue export with pagination metadata |
| `/projects` | Project registry list (#427) |
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/projects` | JSON registry export |
| `/api/v1/projects` | Versioned JSON registry export (#635) |
| `/api/v1/projects/{id}` | Versioned JSON project detail (#635) |
| `/api/projects` | JSON registry export — unversioned Phase 1 alias of `/api/v1/projects` |
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | MCP runtime health and stale detection (#430) |
@@ -66,6 +79,85 @@ Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
local validator preview only (no Gitea mutations, no server-side storage).
## System health API (#634)
`GET /api/v1/system/health` is the structured, read-only health surface for
automated readiness checks. It is the first console API under the `/api/v1`
prefix; the unversioned MVP exports remain as compatibility aliases.
`/health` is unchanged for existing consumers — every MVP key is still present
— and now also carries `started_at`, `uptime_seconds`, and a
`system_health_api` pointer. It stays deliberately cheap and runs no dependency
probe, because answering readiness costs real work.
**Status codes.** `200` when ready, `503` when a required dependency failed or
was never probed. Automation can branch on the code without parsing the body.
**Query flags.** The Gitea check is a network call, so it is opt-in:
`GET /api/v1/system/health?deep=1` runs it and caches the result for
`WEBUI_HEALTH_PROBE_TTL_SECONDS` (default 15s) so dashboard polling does not
amplify into remote load. Without the flag that probe reports `skipped`.
**Dependencies.** `control_plane_db` and `repository` are required and drive
readiness. `gitea` is optional: when it fails the overall `status` degrades but
`readiness.ready` stays true, because local inventory is still serveable. Each
entry carries `status`, `detail`, `required`, and `latency_ms`.
Two honesty rules are worth knowing before reading the payload:
* `stale_runtime.mutation_safe` is true only when the runtime, checkout, and
remote-tracking commits are all known and equal. An unfetched remote is
reported as indeterminate, never as safe.
* `mcp_namespaces` entries are always `unproven`. A web process runs outside
the IDE-managed MCP client and cannot invoke a namespace tool, so per #543
only a `client_namespace` probe can prove that path.
Sample response (abridged, healthy):
```json
{
"status": "ok",
"service": "mcp-control-plane-webui",
"mode": "read-only",
"api": "/api/v1/system/health",
"timestamp": "2026-07-22T11:04:18.512034+00:00",
"readiness": { "ready": true, "complete": true, "reasons": [] },
"version": {
"git_sha": "620ed6e9a9550b8da2ceb82d9ab8744e8920490f",
"git_describe": "v1.1.0-898-g620ed6e",
"control_plane_schema_version": 4,
"python_version": "3.14.5",
"known": true
},
"process": { "started_at": "2026-07-22T10:58:02.114+00:00", "uptime_seconds": 376.4 },
"deep_probes_requested": false,
"dependencies": [
{
"name": "control_plane_db",
"kind": "sqlite",
"status": "ok",
"detail": "schema v4 readable",
"required": true,
"healthy": true,
"latency_ms": 1.482,
"metadata": { "schema_version": 4, "active_leases": 3 }
},
{ "name": "repository", "kind": "git", "status": "ok", "required": true, "healthy": true },
{ "name": "gitea", "kind": "http", "status": "skipped", "required": false, "healthy": false }
],
"mcp_namespaces": [
{ "namespace": "gitea-author", "required_tool": "gitea_whoami", "status": "unproven" }
],
"stale_runtime": { "stale": false, "determinable": true, "mutation_safe": true, "reasons": [] },
"probe_errors": []
}
```
No restart, reload, or process-kill control is exposed here: those are Phase 2
at the earliest, and #630 forbids process-kill recovery outright. Every probe
opens its subject read-only — the control-plane database is opened through a
`mode=ro` URI so a health check can never create or migrate a schema.
## Report audit (#431)
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
+213
View File
@@ -0,0 +1,213 @@
# Project registry API (#635)
Phase 1 of the [console architecture ADR](architecture/webui-control-plane-console-architecture-adr.md)
gives the project registry a versioned, read-only API. This document is the
field-by-field contract for that API and for the registry file behind it.
Everything here is **read-only**. The console never writes the registry; an
operator edits the JSON file, and an invalid file fails closed rather than
rendering a partial inventory.
## Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/api/v1/projects` | GET | Versioned registry export: all projects, with provenance |
| `/api/v1/projects/{project_id}` | GET | Single project; `404` with `project_not_found` when unknown |
| `/api/projects` | GET | Unversioned MVP alias (#427), retained for all of Phase 1 |
| `/projects` | GET | HTML list — status and onboarding progress per project |
| `/projects/{project_id}` | GET | HTML detail — identity, profiles, paths, checklist |
Per ADR section 6 the unversioned alias may be retired no earlier than Phase 2,
and only after this document and `webui-local-dev.md` record the swap. The alias
returns the same payload as `/api/v1/projects`, including the legacy `version`
and `source_path` keys #427 consumers already read.
The HTML views render from the same DTO the JSON routes serialize
(`project_to_dict`), so the console and the API cannot disagree about a
project's status or onboarding progress.
## Registry file
Default location: `webui/data/projects.registry.json`. Override with the
`WEBUI_PROJECT_REGISTRY` environment variable.
Schema versions: **1** and **2** are accepted; **2** is current. A version 1
file loads unchanged and is normalized with the documented defaults, so an
existing operator registry keeps working without edits.
### Root
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `version` | int | yes | `1` or `2`. Anything else fails closed |
| `projects` | array | yes | Must be non-empty |
### Project
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `id` | string | yes | — | Stable registry id used in URLs |
| `repo_name` | string | yes | — | Gitea repository name |
| `gitea_owner` | string | yes | — | Owning org or user |
| `remote_host` | string | yes | — | Instance base URL, no credentials |
| `remote_name` | string | no | `null` | Logical remote label, e.g. `prgs` (v2) |
| `default_branch` | string | yes | — | Stable branch name |
| `local_checkout_path` | string | yes | — | Control checkout path |
| `status` | string | no | `active` | `active`, `onboarding`, `paused`, `archived` (v2) |
| `profiles` | object | yes | — | Must map `author`, `reviewer`, `reconciler` |
| `workflow_paths` | object | yes | — | Non-empty; label to repo-relative path |
| `schema_paths` | object | no | `{}` | Label to repo-relative path |
| `onboarding_checklist` | array | no | `[]` | See below |
| `last_seen_health` | object | no | `null` | Redacted health only (v2) |
### Onboarding step
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `id` | string | yes | — | Stable step id |
| `title` | string | yes | — | Short operator-facing label |
| `description` | string | yes | — | Self-contained; assumes no chat history |
| `state` | string | no | `pending` | `complete`, `pending`, `blocked`, `not_applicable` (v2) |
| `required` | bool | no | `true` | Optional steps never block readiness (v2) |
### Last-seen health
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `status` | string | no (default `unknown`) | `healthy`, `degraded`, `unreachable`, `unknown` |
| `checked_at` | string | no | ISO-8601 UTC timestamp, e.g. `2026-01-01T00:00:00Z` |
| `detail` | string | no | Short redacted note |
Health is recorded metadata, not a live probe: Phase 1 performs no outbound
health checks. Endpoints, tokens, and keychain identifiers must never appear
here.
## Response shape
`GET /api/v1/projects`:
```json
{
"api_version": "v1",
"schema_version": 2,
"version": 2,
"source_path": "/path/to/webui/data/projects.registry.json",
"source": {
"kind": "file",
"path": "/path/to/webui/data/projects.registry.json",
"inventory_complete": true
},
"project_count": 1,
"projects": [
{
"id": "example",
"repo_name": "Example",
"gitea_owner": "Org",
"repo_full_name": "Org/Example",
"remote_host": "https://gitea.example.invalid",
"remote_name": "example-remote",
"default_branch": "main",
"local_checkout_path": ".",
"status": "active",
"profiles": {"author": "...", "reviewer": "...", "reconciler": "..."},
"workflow_paths": {"skill": "skills/..."},
"schema_paths": {},
"onboarding_checklist": [
{
"id": "profiles",
"title": "Configure execution profiles",
"description": "...",
"state": "complete",
"required": true
}
],
"onboarding_summary": {
"total": 1,
"complete": 1,
"pending": 0,
"blocked": 0,
"not_applicable": 0,
"required_outstanding": 0,
"onboarding_complete": true
},
"last_seen_health": null
}
]
}
```
`GET /api/v1/projects/{project_id}` returns `api_version`, `schema_version`,
`source`, and a single `project` object with the same fields.
The `source` block satisfies the ADR section 6 provenance rule: every payload
states where the data came from and whether the inventory is complete. A
file-backed registry is always complete — there is no pagination to truncate it.
`onboarding_summary` is derived, never stored. `required_outstanding` counts
steps that are `required` **and** in state `pending` or `blocked`;
`onboarding_complete` is true when that count is zero.
## Fail-closed errors
Validation failures raise `RegistryError`, which routes render instead of a
traceback.
`404` — unknown project id on `/api/v1/projects/{project_id}`:
```json
{
"error": "project_not_found",
"project_id": "not-registered",
"known_project_ids": ["example"],
"remediation": "Request one of the known project ids, or add the project ...",
"source": {"kind": "file", "path": "...", "inventory_complete": true}
}
```
`500` — invalid registry, on both the versioned route and the alias:
```json
{
"error": "registry_invalid",
"detail": "unsupported registry version: 42",
"remediation": "Set 'version' to one of 1, 2 (current schema is 2) ...",
"field_path": "version",
"source_path": "/path/to/registry.json"
}
```
`field_path` points at the offending location (`projects[0].profiles.reconciler`,
`projects[0].onboarding_checklist[2].state`, and so on). The HTML routes render
the same detail, field, source, and remediation on a "Project registry
unavailable" page.
Conditions that fail closed:
* file missing or unreadable;
* invalid JSON (the remediation names line and column);
* root not an object, or `projects` missing/empty;
* unsupported `version`;
* a credential-shaped key anywhere in the file (`token`, `*_secret`, `auth_*`, and similar);
* a project missing a required field, or missing an `author`/`reviewer`/`reconciler` profile;
* an unknown `status`, onboarding `state`, or health `status`.
## Credential rule
The registry stores redacted metadata only. Credential-shaped keys are
rejected at load time, before any DTO is built, consistent with
[safety-model.md](safety-model.md) and
[credential-isolation.md](credential-isolation.md). Tokens live in the keychain
and are resolved server-side by `gitea_auth`.
## Migrating a version 1 registry
1. Set `"version": 2`.
2. Optionally add `"status"` per project (omitted means `active`).
3. Optionally add `"remote_name"` per project.
4. Optionally add `"state"` and `"required"` to each onboarding step (omitted
means `pending` and `true`).
5. Optionally add `"last_seen_health"`.
No step is mandatory: a version 1 file keeps loading. Bumping the version only
declares that the file may use the v2 fields.
+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, ()):
+3105 -205
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"),
+481
View File
@@ -0,0 +1,481 @@
"""Exact-owner renewal of an expired author issue lease (#760).
An author issue lease carries an absolute wall-clock expiry stamped once at
lock time. The PID recorded alongside it is the long-lived MCP daemon, not the
authoring task, so a lease that expires while its daemon is still up is the
ordinary case for any author task that outlives the TTL — not an anomaly.
Before this module, that case was unreachable.
``issue_lock_store.assess_same_issue_lease_conflict`` computed same-owner
evidence and then returned on the expired branch before consulting it, and
``assess_expired_lock_reclaim`` only permits takeover on a dead PID or a
missing worktree. An exact owner whose daemon is alive and whose worktree is
present satisfied neither, so its own lock became permanently unmodifiable
through sanctioned tools.
This module is the pure evidence assessor for that one narrow case. It answers
a single question: may *this* session renew a lease it can prove it already
owns? It performs no mutation and no network I/O, and it never trusts a caller
assertion — every field is compared against durable lock state or a live
observation supplied by the caller and gathered server-side.
Deliberate boundaries:
* **Renewal is not takeover.** A refusal here never widens what
``assess_expired_lock_reclaim`` already allows; foreign expired locks keep
requiring a dead PID or missing worktree (#760 AC11), and a *live* foreign
lease stays non-recoverable by construction because only an expired lease is
ever a candidate (AC12).
* **PID liveness is never authorization.** A live recorded PID proves the
daemon is up, nothing more. It is recorded as evidence and is neither
necessary nor sufficient for renewal (AC16).
* **Absolute expiry is preserved.** Renewal issues a new absolute expiry from
the moment of the write. It does not introduce sliding heartbeat renewal,
lease generations as fencing tokens, or a shared cross-role lifecycle — that
is #790's scope and is deliberately not implemented here.
"""
from __future__ import annotations
import os
from typing import Any, Iterable, Mapping, Sequence
from issue_lock_store import AUTHOR_ISSUE_WORK_LEASE, is_lease_expired, is_process_alive
from reviewer_worktree import parse_dirty_tracked_files
# Outcome values.
RENEWAL_SANCTIONED = "RENEWAL_SANCTIONED"
NO_CANDIDATE = "NO_CANDIDATE"
REFUSED = "REFUSED"
# Durable fields a lock must carry before it can be considered at all.
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
def _text(value: Any) -> str:
return str(value or "").strip()
def _same_realpath(left: str | None, right: str | None) -> bool:
if not left or not right:
return False
try:
return os.path.realpath(left) == os.path.realpath(right)
except OSError:
return left == right
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
claimant = lock.get("claimant")
if not isinstance(claimant, Mapping):
lease = lock.get("work_lease")
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
return dict(claimant) if isinstance(claimant, Mapping) else {}
def _lock_lease(lock: Mapping[str, Any]) -> dict[str, Any]:
lease = lock.get("work_lease")
return dict(lease) if isinstance(lease, Mapping) else {}
def _lock_operation_type(lock: Mapping[str, Any]) -> str:
lease = _lock_lease(lock)
return _text(lease.get("operation_type")) or AUTHOR_ISSUE_WORK_LEASE
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
pid = lock.get("session_pid")
if pid is None:
pid = lock.get("pid")
return pid
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
"""Names of durable fields that are missing or unusable."""
missing: list[str] = []
for field in REQUIRED_LOCK_FIELDS:
if not _text(lock.get(field)):
missing.append(field)
pid = _recorded_pid(lock)
if pid is None or _text(pid) == "":
missing.append("session_pid/pid")
else:
try:
if int(pid) <= 0:
missing.append("session_pid/pid")
except (TypeError, ValueError):
missing.append("session_pid/pid")
return missing
def _competing_lock_reasons(
competing_live_locks: Iterable[Mapping[str, Any]] | None,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
) -> list[str]:
"""Live locks that would contend with this renewal (#760 AC7).
A live lock on the *same* issue cannot coexist with this expired lease, so
any live entry naming this issue, branch, or worktree belongs to somebody
else and refuses the renewal.
"""
reasons: list[str] = []
for entry in competing_live_locks or ():
if not isinstance(entry, Mapping):
continue
entry_issue = entry.get("issue_number")
entry_branch = _text(entry.get("branch_name"))
entry_worktree = _text(entry.get("worktree_path"))
if entry_issue == issue_number:
reasons.append(
f"a live lock already exists for issue #{issue_number} "
f"(pid {entry.get('pid')}); renewal would contend with it"
)
continue
if entry_branch and entry_branch == _text(branch_name):
reasons.append(
f"live lock for issue #{entry_issue} already holds branch "
f"'{branch_name}'"
)
if entry_worktree and _same_realpath(entry_worktree, worktree_path):
reasons.append(
f"live lock for issue #{entry_issue} already holds worktree "
f"'{worktree_path}'"
)
return reasons
def assess_exact_owner_lease_renewal(
existing_lock: Mapping[str, Any] | None,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
current_branch: str | None = None,
porcelain_status: str = "",
worktree_exists: bool = False,
head_sha: str | None = None,
remote_head_sha: str | None = None,
pr_head_sha: str | None = None,
pr_number: int | None = None,
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
candidate_branches: Sequence[str] | None = None,
current_pid: int | None = None,
now: Any = None,
) -> dict[str, Any]:
"""Decide whether an expired lease may be renewed by its exact owner.
Returns a disposition dict; it never raises and never mutates. A refusal
withholds permission, leaving every pre-existing guard to fail closed
exactly as before — this assessment can only ever *add* permission.
``NO_CANDIDATE`` means the situation is not an exact-owner renewal at all
(no lock, different issue, different operation, or an unexpired lease) and
the caller should carry on with its normal path. ``REFUSED`` means it looked
like one but the evidence did not hold, and ``reasons`` names exactly what
was missing.
"""
evidence: dict[str, Any] = {
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": worktree_path,
"remote": remote,
"org": org,
"repo": repo,
"operation_type": operation_type,
"identity": identity,
"profile": profile,
}
def _result(outcome: str, reasons: list[str], **extra: Any) -> dict[str, Any]:
return {
"outcome": outcome,
"renewal_sanctioned": outcome == RENEWAL_SANCTIONED,
"is_candidate": outcome in (RENEWAL_SANCTIONED, REFUSED),
"reasons": reasons,
"evidence": {**evidence, **extra},
}
if not isinstance(existing_lock, Mapping) or not existing_lock:
return _result(NO_CANDIDATE, ["no existing lock to renew"])
if existing_lock.get("issue_number") != issue_number:
return _result(
NO_CANDIDATE,
[
f"existing lock is for issue #{existing_lock.get('issue_number')}, "
f"not #{issue_number}"
],
)
existing_operation = _lock_operation_type(existing_lock)
if existing_operation != operation_type:
return _result(
NO_CANDIDATE,
[
f"existing lease operation '{existing_operation}' is not "
f"'{operation_type}'"
],
)
# Only an *expired* lease is ever a renewal candidate. An unexpired lease —
# live, or stale by dead PID — is somebody else's problem: the first needs no
# renewal, and the second is #753's dead-session recovery. This is also what
# makes a live foreign lease non-recoverable here (#760 AC12).
if not is_lease_expired(existing_lock, now=now):
return _result(
NO_CANDIDATE,
["lease has not expired; renewal does not apply"],
)
malformed = _malformed_reasons(existing_lock)
if malformed:
return _result(
REFUSED,
["durable lock is missing or has unusable fields: " + ", ".join(malformed)],
)
lease = _lock_lease(existing_lock)
claimant = _lock_claimant(existing_lock)
recorded_pid = _recorded_pid(existing_lock)
prior_expires_at = _text(lease.get("expires_at"))
# #760 AC16: recorded purely as evidence. A live daemon PID is neither
# necessary nor sufficient for renewal, and nothing below branches on it.
recorded_pid_alive = is_process_alive(recorded_pid)
extra: dict[str, Any] = {
"prior_pid": recorded_pid,
"prior_pid_alive": recorded_pid_alive,
"prior_expires_at": prior_expires_at,
"replacement_pid": current_pid,
"recorded_claimant": claimant,
"head_sha": head_sha,
"remote_head_sha": remote_head_sha,
"pr_head_sha": pr_head_sha,
"pr_number": pr_number,
}
reasons: list[str] = []
# ── AC3: exact ownership identity ──
if _text(existing_lock.get("remote")) != _text(remote):
reasons.append(
f"recorded remote '{existing_lock.get('remote')}' does not match "
f"'{remote}'"
)
if _text(existing_lock.get("org")) != _text(org):
reasons.append(
f"recorded org '{existing_lock.get('org')}' does not match '{org}'"
)
if _text(existing_lock.get("repo")) != _text(repo):
reasons.append(
f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'"
)
if _text(existing_lock.get("branch_name")) != _text(branch_name):
reasons.append(
f"recorded branch '{existing_lock.get('branch_name')}' does not match "
f"'{branch_name}'"
)
if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path):
reasons.append(
f"recorded worktree '{existing_lock.get('worktree_path')}' does not "
f"match '{worktree_path}'"
)
recorded_identity = _text(claimant.get("username"))
recorded_profile = _text(claimant.get("profile"))
if not recorded_identity or not recorded_profile:
reasons.append(
"durable lock does not record both a claimant username and profile"
)
if recorded_identity and recorded_identity != _text(identity):
reasons.append(
f"recorded claimant '{recorded_identity}' does not match active "
f"identity '{_text(identity) or 'unknown'}'"
)
if recorded_profile and recorded_profile != _text(profile):
reasons.append(
f"recorded profile '{recorded_profile}' does not match active profile "
f"'{_text(profile) or 'unknown'}'"
)
# ── AC4: the registered worktree still exists, is on the branch, and is clean ──
if not worktree_exists:
reasons.append(f"declared worktree '{worktree_path}' does not exist")
if _text(current_branch) != _text(branch_name):
reasons.append(
f"worktree is on branch '{_text(current_branch) or 'unknown'}', not "
f"'{branch_name}'"
)
dirty = parse_dirty_tracked_files(porcelain_status or "")
if dirty:
reasons.append(
"worktree has uncommitted tracked changes: " + ", ".join(sorted(dirty))
)
# ── AC5/AC6: published heads must agree ──
if not _text(head_sha):
reasons.append("local head could not be observed")
if not _text(remote_head_sha):
reasons.append(
"remote branch head could not be observed; an unpublished branch "
"cannot prove exact-owner renewal"
)
if _text(head_sha) and _text(remote_head_sha) and head_sha != remote_head_sha:
reasons.append(
f"local head {head_sha} does not equal remote head {remote_head_sha}"
)
if pr_number is not None:
if not _text(pr_head_sha):
reasons.append(f"owning PR #{pr_number} head could not be observed")
elif _text(head_sha) and pr_head_sha != head_sha:
reasons.append(
f"owning PR #{pr_number} head {pr_head_sha} does not equal local "
f"head {head_sha}"
)
# ── AC7: nothing else claims this work ──
reasons.extend(
_competing_lock_reasons(
competing_live_locks,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=worktree_path,
)
)
other_branches = [
name
for name in (candidate_branches or ())
if _text(name) and _text(name) != _text(branch_name)
]
if other_branches:
reasons.append(
"other branches already carry this issue marker: "
+ ", ".join(sorted(other_branches))
)
if reasons:
return _result(REFUSED, reasons, **extra)
return _result(
RENEWAL_SANCTIONED,
[
f"exact owner '{recorded_identity}' ({recorded_profile}) proved "
f"ownership of issue #{issue_number} on branch '{branch_name}' from "
f"worktree '{worktree_path}'; local, remote"
+ (f", and PR #{pr_number}" if pr_number is not None else "")
+ f" heads all equal {head_sha}; lease expired at "
f"{prior_expires_at or 'unknown'}"
],
**extra,
)
def owning_pr_renewal_evidence(
assessment: Mapping[str, Any] | None,
) -> dict[str, Any] | None:
"""Server-derived proof of the open PR a sanctioned renewal already owns.
The mirror of ``issue_lock_recovery.owning_pr_recovery_evidence`` (#755) for
the renewal disposition. An exact-owner renewal of a published branch is, by
construction, renewal of work that already has an open PR — so the
duplicate-work gate's linked-open-PR blocker would otherwise discard every
sanctioned renewal, exactly as it once discarded every sanctioned recovery.
Returns ``None`` unless renewal was actually granted and the evidence names
one owning PR whose head agrees with both the local and remote heads the
assessor accepted. Nothing is caller-supplied: every field is copied from
evidence built out of durable lock state plus live git/Gitea observation.
Renewal has no descendant case — it requires the local, remote, and PR heads
to be equal — so there is only one head to report.
"""
if not isinstance(assessment, Mapping):
return None
if assessment.get("outcome") != RENEWAL_SANCTIONED:
return None
if not assessment.get("renewal_sanctioned"):
return None
evidence = assessment.get("evidence") or {}
branch_name = _text(evidence.get("branch_name"))
pr_head = _text(evidence.get("pr_head_sha"))
local_head = _text(evidence.get("head_sha"))
remote_head = _text(evidence.get("remote_head_sha"))
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:
return None
try:
pr_number = int(raw_pr_number)
issue_number = int(evidence.get("issue_number"))
except (TypeError, ValueError):
return None
return {
"issue_number": issue_number,
"pr_number": pr_number,
"branch_name": branch_name,
"head_sha": pr_head,
"recorded_head": pr_head,
"accepted_head": pr_head,
"head_relation": "equal",
}
def build_renewal_record(
assessment: Mapping[str, Any] | None,
*,
renewed_at: str,
new_expires_at: str,
) -> dict[str, Any]:
"""Durable audit record for a sanctioned renewal (#760 AC9).
Records both sides of the transition — prior PID and expiry, replacement PID
and new expiry — so a renewed lock is never mistakable for an original
claim, and so the evidence the waiver was granted on stays inspectable.
"""
data = dict(assessment or {})
evidence = dict(data.get("evidence") or {})
recorded_claimant = dict(evidence.get("recorded_claimant") or {})
return {
"renewed": bool(data.get("renewal_sanctioned")),
"renewed_at": renewed_at,
"prior_pid": evidence.get("prior_pid"),
"prior_pid_alive": evidence.get("prior_pid_alive"),
"prior_expires_at": evidence.get("prior_expires_at"),
"replacement_pid": evidence.get("replacement_pid"),
"new_expires_at": new_expires_at,
"identity": recorded_claimant.get("username"),
"profile": recorded_claimant.get("profile"),
"branch_name": evidence.get("branch_name"),
"worktree_path": evidence.get("worktree_path"),
"head_sha": evidence.get("head_sha"),
"remote_head_sha": evidence.get("remote_head_sha"),
"pr_head_sha": evidence.get("pr_head_sha"),
"pr_number": evidence.get("pr_number"),
"reason": "expired lease renewed by its exact recorded owner",
"proof": list(data.get("reasons") or []),
}
def format_renewal_refusal(assessment: Mapping[str, Any] | None) -> str:
"""One-line refusal summary for a blocked caller."""
data = dict(assessment or {})
reasons = list(data.get("reasons") or [])
if not reasons:
return "exact-owner lease renewal was not available (no evidence recorded)"
return "exact-owner lease renewal refused: " + "; ".join(reasons)
+621 -32
View File
@@ -15,15 +15,27 @@ import json
import os
import re
import tempfile
import uuid
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any
import lease_policy
LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
WORK_LEASE_TTL_HOURS = 4
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
# Freshness classifications. ``STATUS_STALE`` remains the dead-PID band that
# #753 recovery keys on; the two bands below are new in #790 Slice A and apply
# only to leases minted under the heartbeat lifecycle.
STATUS_LIVE = "live"
STATUS_EXPIRED = "expired"
STATUS_ABSENT = "absent"
STATUS_STALE = "stale"
STATUS_STALE_MISSED_HEARTBEAT = "stale_missed_heartbeat"
STATUS_STALE_ABSOLUTE_CAP = "stale_absolute_cap"
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
@@ -148,8 +160,38 @@ 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,
renewal_sanctioned: bool = False,
) -> 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 "")
@@ -191,9 +233,24 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
issue_number=issue_number,
branch_name=str(record.get("branch_name") or ""),
worktree_path=str(record.get("worktree_path") or ""),
renewal_sanctioned=renewal_sanctioned,
)
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:
@@ -208,6 +265,331 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
return path
def _ownership_refusals(
lock: dict[str, Any],
*,
issue_number: int,
branch_name: str,
worktree_path: str,
identity: str | None,
profile: str | None,
) -> list[str]:
"""Exact-ownership mismatches between a durable lock and a live caller.
Shared by the heartbeat writer and the legacy rebind path so the two cannot
disagree about what "the same owner" means. Every field is compared against
durable state; nothing is taken on the caller's word beyond the identity the
server itself resolved.
"""
reasons: list[str] = []
if lock.get("issue_number") != issue_number:
reasons.append(
f"lock targets issue #{lock.get('issue_number')}, not #{issue_number}"
)
if str(lock.get("branch_name") or "") != str(branch_name or ""):
reasons.append(
f"lock branch '{lock.get('branch_name')}' does not match '{branch_name}'"
)
if not _same_realpath(str(lock.get("worktree_path") or ""), worktree_path):
reasons.append(
f"lock worktree '{lock.get('worktree_path')}' does not match "
f"'{worktree_path}'"
)
lease = lock.get("work_lease") if isinstance(lock, dict) else None
claimant = lease.get("claimant") if isinstance(lease, dict) else None
claimant = claimant if isinstance(claimant, dict) else {}
recorded_identity = str(claimant.get("username") or "").strip()
recorded_profile = str(claimant.get("profile") or "").strip()
if not recorded_identity or not recorded_profile:
reasons.append("lock does not record both a claimant username and profile")
if recorded_identity and recorded_identity != str(identity or "").strip():
reasons.append(
f"lock claimant '{recorded_identity}' does not match active identity "
f"'{str(identity or '').strip() or 'unknown'}'"
)
if recorded_profile and recorded_profile != str(profile or "").strip():
reasons.append(
f"lock profile '{recorded_profile}' does not match active profile "
f"'{str(profile or '').strip() or 'unknown'}'"
)
return reasons
def _refusal(reasons: list[str], **extra: Any) -> dict[str, Any]:
return {"success": False, "performed": False, "reasons": reasons, **extra}
def heartbeat_session_lock(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
branch_name: str,
worktree_path: str,
identity: str | None,
profile: str | None,
task_session_id: str,
expected_generation: int | None = None,
lock_dir: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Slide a heartbeat-lifecycle lease forward (#790 Slice A, A4).
The write happens inside the same per-issue ``flock`` that serializes
acquisition, and under the #772 generation compare-and-swap, so a heartbeat
can never race a concurrent reclaim: whichever lands first moves the
generation and the other fails closed.
Refuses — never revives — in every ambiguous case. A lease that has already
lapsed past its grace is *not* heartbeatable: allowing that would let a
session that stopped proving liveness restore ownership retroactively, which
is precisely the revival AC-N5 forbids. Such a session must go through the
sanctioned reclaim path, which mints a fresh generation.
"""
current = _lease_now(now)
root = _ensure_lock_dir(lock_dir)
path = lock_file_path(
remote=remote, org=org, repo=repo, issue_number=issue_number, lock_dir=root
)
declared_session = str(task_session_id or "").strip()
if not declared_session:
return _refusal(["no task_session_id supplied (fail closed)"])
sentinel = flock_path(path)
try:
with _exclusive_file_lock(sentinel):
lock = read_lock_file(path)
if not lock:
return _refusal([f"no durable lock for issue #{issue_number}"])
if is_legacy_lease(lock):
return _refusal(
[
"lock predates the heartbeat lifecycle; it must be rebound "
"by its exact owner before it can be heartbeated"
],
lifecycle=lease_lifecycle_version(lock),
legacy_lease=True,
)
reasons = _ownership_refusals(
lock,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=worktree_path,
identity=identity,
profile=profile,
)
recorded_session = lease_task_session_id(lock)
if not recorded_session:
reasons.append(
"lock declares the heartbeat lifecycle but records no "
"task_session_id (fail closed)"
)
elif recorded_session != declared_session:
# A superseded session holding an old identifier cannot heartbeat
# over the session that replaced it.
reasons.append(
"task_session_id does not match the session recorded on the lock"
)
if reasons:
return _refusal(reasons)
current_generation = lock_generation(lock)
if (
expected_generation is not None
and current_generation != expected_generation
):
return _refusal(
[
f"lock generation changed: expected {expected_generation}, "
f"found {current_generation}; another session reclaimed or "
"replaced this claim (fail closed)"
],
lock_generation=current_generation,
)
freshness = assess_lock_freshness(lock, now=current)
if not freshness.get("live"):
return _refusal(
[
f"lease is not live ({freshness.get('status')}): "
f"{freshness.get('reason')}; a lapsed lease must be "
"reclaimed, not heartbeated"
],
freshness=freshness,
)
policy = lease_policy.policy_for(lease_task_class(lock))
expires = current + timedelta(minutes=policy.initial_ttl_minutes)
record = dict(lock)
lease = dict(record.get("work_lease") or {})
prior_heartbeat = lease.get("last_heartbeat_at")
lease["last_heartbeat_at"] = _format_lease_timestamp(current)
lease["expires_at"] = _format_lease_timestamp(expires)
try:
lease["heartbeat_count"] = int(lease.get("heartbeat_count") or 0) + 1
except (TypeError, ValueError):
lease["heartbeat_count"] = 1
record["work_lease"] = lease
record["lock_generation"] = current_generation + 1
save_lock_file(path, record)
except LockContentionError as exc:
return _refusal([f"issue #{issue_number} lock contention: {exc} (fail closed)"])
return {
"success": True,
"performed": True,
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": worktree_path,
"task_session_id": declared_session,
"lock_generation": record["lock_generation"],
"prior_generation": current_generation,
"prior_heartbeat_at": prior_heartbeat,
"last_heartbeat_at": lease["last_heartbeat_at"],
"expires_at": lease["expires_at"],
"heartbeat_count": lease["heartbeat_count"],
"lock_file_path": path,
"policy": lease_policy.describe(lease_task_class(record)),
"freshness": assess_lock_freshness(record, now=current),
}
def rebind_legacy_lock(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
branch_name: str,
worktree_path: str,
identity: str | None,
profile: str | None,
expected_generation: int | None = None,
lock_dir: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Move a legacy lock into the heartbeat lifecycle (#790 AC-N8).
One of the two sanctioned exits from the preserved-expiry legacy state; the
other is terminal retirement, which is Slice B. Only the exact recorded
owner may rebind, and only while the legacy lock is still live under its
original absolute expiry — an already-expired legacy lease belongs to the
#760 renewal path or #601 reclaim, and this must not become a second, weaker
way to revive one.
The rebind mints a genuine task-session identifier and a genuine first
heartbeat. It does not fabricate history: the original creation and expiry
are preserved under ``legacy_origin`` for audit, and the new lifecycle's
absolute cap runs from the rebind, not from the legacy claim.
"""
current = _lease_now(now)
root = _ensure_lock_dir(lock_dir)
path = lock_file_path(
remote=remote, org=org, repo=repo, issue_number=issue_number, lock_dir=root
)
sentinel = flock_path(path)
try:
with _exclusive_file_lock(sentinel):
lock = read_lock_file(path)
if not lock:
return _refusal([f"no durable lock for issue #{issue_number}"])
if not is_legacy_lease(lock):
return _refusal(
[
"lock is already on the heartbeat lifecycle; use the "
"heartbeat path"
],
lifecycle=lease_lifecycle_version(lock),
legacy_lease=False,
)
reasons = _ownership_refusals(
lock,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=worktree_path,
identity=identity,
profile=profile,
)
if reasons:
return _refusal(reasons)
current_generation = lock_generation(lock)
if (
expected_generation is not None
and current_generation != expected_generation
):
return _refusal(
[
f"lock generation changed: expected {expected_generation}, "
f"found {current_generation} (fail closed)"
],
lock_generation=current_generation,
)
freshness = assess_lock_freshness(lock, now=current)
if not freshness.get("live"):
return _refusal(
[
f"legacy lease is not live ({freshness.get('status')}): "
f"{freshness.get('reason')}; rebinding is not a recovery "
"path for a lapsed lease"
],
freshness=freshness,
)
policy = lease_policy.policy_for(lease_task_class(lock))
expires = current + timedelta(minutes=policy.initial_ttl_minutes)
session_id = mint_task_session_id(lease_task_class(lock))
record = dict(lock)
lease = dict(record.get("work_lease") or {})
legacy_origin = {
"created_at": lease.get("created_at"),
"expires_at": lease.get("expires_at"),
"last_heartbeat_at": lease.get("last_heartbeat_at"),
"lifecycle": lease_policy.LIFECYCLE_LEGACY,
}
lease["lifecycle_version"] = lease_policy.LIFECYCLE_HEARTBEAT_V1
lease["task_session_id"] = session_id
lease["created_at"] = _format_lease_timestamp(current)
lease["last_heartbeat_at"] = _format_lease_timestamp(current)
lease["expires_at"] = _format_lease_timestamp(expires)
lease["heartbeat_count"] = 1
record["work_lease"] = lease
record["legacy_rebind"] = {
"rebound_at": _format_lease_timestamp(current),
"task_session_id": session_id,
"prior_generation": current_generation,
"legacy_origin": legacy_origin,
"reason": (
"legacy lock rebound into the heartbeat lifecycle by its exact "
"recorded owner"
),
}
record["lock_generation"] = current_generation + 1
save_lock_file(path, record)
except LockContentionError as exc:
return _refusal([f"issue #{issue_number} lock contention: {exc} (fail closed)"])
return {
"success": True,
"performed": True,
"issue_number": issue_number,
"task_session_id": session_id,
"lock_generation": record["lock_generation"],
"prior_generation": current_generation,
"lifecycle": lease_policy.LIFECYCLE_HEARTBEAT_V1,
"legacy_rebind": record["legacy_rebind"],
"expires_at": lease["expires_at"],
"last_heartbeat_at": lease["last_heartbeat_at"],
"lock_file_path": path,
"freshness": assess_lock_freshness(record, now=current),
}
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
root = (lock_dir or default_lock_dir()).strip()
pointer = read_lock_file(session_pointer_path(root))
@@ -291,6 +673,16 @@ def _parse_lease_timestamp(value: str | None) -> datetime | None:
return None
def _format_lease_timestamp(value: datetime) -> str:
"""Serialize a lease timestamp in the durable ``...Z`` form already on disk."""
return (
value.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
if not lock:
return None
@@ -311,60 +703,216 @@ def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -
return assess_lock_freshness(lock, now=now)["live"]
def lease_task_class(lock_data: dict[str, Any] | None) -> str:
"""Policy task class for a durable lock; author work when unrecorded."""
lease = lock_data.get("work_lease") if isinstance(lock_data, dict) else None
if isinstance(lease, dict):
recorded = str(lease.get("operation_type") or "").strip()
if recorded:
return recorded
return AUTHOR_ISSUE_WORK_LEASE
def lease_lifecycle_version(lock_data: dict[str, Any] | None) -> str:
"""Read the durable lifecycle marker (#790 AC-N8).
The marker is the *only* discriminator between a heartbeat-lifecycle lease
and a legacy one. Timestamps are deliberately not consulted: a lock minted
before this lifecycle existed has ``last_heartbeat_at == created_at``
forever, and reading that equality as "recently heartbeated" would treat
every never-heartbeated legacy lock as fresh — the precise inversion AC-N8
forbids. A newly minted heartbeat lease also has the two equal, so the
equality carries no information in either direction.
"""
lease = lock_data.get("work_lease") if isinstance(lock_data, dict) else None
if isinstance(lease, dict):
recorded = str(lease.get("lifecycle_version") or "").strip()
if recorded:
return recorded
return lease_policy.LIFECYCLE_LEGACY
def is_legacy_lease(lock_data: dict[str, Any] | None) -> bool:
"""True when a lock predates the shared heartbeat lifecycle."""
return lease_lifecycle_version(lock_data) != lease_policy.LIFECYCLE_HEARTBEAT_V1
def lease_task_session_id(lock_data: dict[str, Any] | None) -> str:
"""Recorded per-task session identifier, or empty for a legacy lock."""
lease = lock_data.get("work_lease") if isinstance(lock_data, dict) else None
if isinstance(lease, dict):
return str(lease.get("task_session_id") or "").strip()
return ""
def mint_task_session_id(task_class: str = AUTHOR_ISSUE_WORK_LEASE) -> str:
"""Mint an ownership key for one task (#790 AC-N1).
Deliberately contains no process identifier. The recorded PID belongs to the
long-lived MCP daemon, which outlives any individual task and is reused by
every task it serves, so PID digits cannot identify *which* task holds a
claim. The PID is still recorded alongside this value as evidence.
"""
prefix = _sanitize_segment(str(task_class or AUTHOR_ISSUE_WORK_LEASE))
return f"{prefix}-{uuid.uuid4().hex[:16]}"
def _lease_heartbeat_at(lock_data: dict[str, Any] | None) -> datetime | None:
lease = lock_data.get("work_lease") if isinstance(lock_data, dict) else None
heartbeat_at = None
if isinstance(lock_data, dict):
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
if heartbeat_at is None and isinstance(lease, dict):
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
return heartbeat_at
def assess_lock_freshness(
lock_data: dict[str, Any] | None,
*,
now: datetime | None = None,
) -> dict[str, Any]:
"""Classify a lock as live, expired, stale, or absent."""
"""Classify a lock as live, expired, stale, or absent.
#790 Slice A makes the heartbeat load-bearing. Before this change
``last_heartbeat_at`` was parsed and then never consulted: liveness was
decided entirely by the absolute ``expires_at`` and by PID liveness, and
since the recorded PID is the long-lived MCP daemon, an abandoned author
task stayed "live" for the full four-hour TTL.
Two rules govern the rewrite:
* **An alive PID never establishes freshness** (AC-N2). It proves the daemon
is up, nothing about the task. It is recorded as evidence and no branch
returns ``live`` because of it.
* **A dead PID still corroborates staleness.** The dead-PID band is
unchanged and still precedes every heartbeat evaluation, so #753
dead-session recovery keys on exactly the classification it always did.
Legacy leases (AC-N8) keep their recorded absolute expiry and are never
evaluated against the short heartbeat grace, so deploying this change cannot
make an existing claim instantly reclaimable.
"""
current = _lease_now(now)
if not lock_data:
return {
"status": "absent",
"status": STATUS_ABSENT,
"live": False,
"stale": False,
"reason": "no lock record",
}
expires_at = lease_expires_at(lock_data)
lease = lock_data.get("work_lease")
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
if heartbeat_at is None and isinstance(lease, dict):
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
expires_at = lease_expires_at(lock_data)
heartbeat_at = _lease_heartbeat_at(lock_data)
created_at = (
_parse_lease_timestamp(lease.get("created_at"))
if isinstance(lease, dict)
else None
)
pid = lock_data.get("session_pid")
if pid is None:
pid = lock_data.get("pid")
# Evidence only. Never consulted to grant liveness (AC-N2).
pid_alive = is_process_alive(pid) if pid is not None else False
if expires_at and expires_at <= current:
return {
"status": "expired",
"live": False,
"stale": True,
"reason": f"lease expired at {expires_at.isoformat()}",
"pid_alive": pid_alive,
}
lifecycle = lease_lifecycle_version(lock_data)
legacy = lifecycle != lease_policy.LIFECYCLE_HEARTBEAT_V1
policy = lease_policy.policy_for(lease_task_class(lock_data))
if pid is not None and not pid_alive:
return {
"status": "stale",
"live": False,
"stale": True,
"reason": f"owner pid {pid} is not alive",
"pid_alive": False,
}
return {
"status": "live",
"live": True,
"stale": False,
"reason": "lock heartbeat and lease are fresh",
evidence: dict[str, Any] = {
"pid_alive": pid_alive,
"lifecycle": lifecycle,
"legacy_lease": legacy,
"task_session_id": lease_task_session_id(lock_data) or None,
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
"expires_at": expires_at.isoformat() if expires_at else None,
}
def _result(status: str, *, live: bool, reason: str, **extra: Any) -> dict[str, Any]:
return {
"status": status,
"live": live,
"stale": not live and status != STATUS_ABSENT,
"reason": reason,
**evidence,
**extra,
}
if legacy:
# AC-N8: the preserved absolute expiry is the only clock for a lock
# written before task-session heartbeats existed.
if expires_at and expires_at <= current:
return _result(
STATUS_EXPIRED,
live=False,
reason=f"lease expired at {expires_at.isoformat()}",
)
if pid is not None and not pid_alive:
return _result(
STATUS_STALE, live=False, reason=f"owner pid {pid} is not alive"
)
return _result(
STATUS_LIVE,
live=True,
reason=(
"legacy lease is within its recorded absolute expiry; the "
"heartbeat grace does not apply retroactively"
),
legacy_expiry_preserved=True,
)
# ── Heartbeat lifecycle ──
if pid is not None and not pid_alive:
# Unchanged dead-PID band: #753 recovery depends on this exact status.
return _result(STATUS_STALE, live=False, reason=f"owner pid {pid} is not alive")
if heartbeat_at is None:
# Contradictory: a heartbeat lease must carry a heartbeat. Fail closed.
return _result(
STATUS_STALE_MISSED_HEARTBEAT,
live=False,
reason=(
f"lease declares lifecycle '{lifecycle}' but records no "
"last_heartbeat_at (fail closed)"
),
)
if policy.absolute_cap_hours and created_at is not None:
cap_at = created_at + timedelta(hours=policy.absolute_cap_hours)
if cap_at <= current:
return _result(
STATUS_STALE_ABSOLUTE_CAP,
live=False,
reason=(
f"lease exceeded its {policy.absolute_cap_hours}h absolute cap "
f"at {cap_at.isoformat()}; canonical re-adoption is required"
),
absolute_cap_at=cap_at.isoformat(),
)
grace_at = heartbeat_at + timedelta(minutes=policy.missed_heartbeat_grace_minutes)
if grace_at <= current or (expires_at is not None and expires_at <= current):
return _result(
STATUS_STALE_MISSED_HEARTBEAT,
live=False,
reason=(
f"no valid heartbeat since {heartbeat_at.isoformat()}; the "
f"{policy.missed_heartbeat_grace_minutes}min grace lapsed at "
f"{grace_at.isoformat()}"
),
missed_heartbeat_since=grace_at.isoformat(),
)
warning_at = heartbeat_at + timedelta(minutes=policy.stale_warning_minutes)
return _result(
STATUS_LIVE,
live=True,
reason="lease heartbeat is fresh within the configured grace",
heartbeat_warning=warning_at <= current,
)
def _same_realpath(left: str | None, right: str | None) -> bool:
if not left or not right:
@@ -401,6 +949,27 @@ def assess_expired_lock_reclaim(
"reasons": ["lock is still live; cannot reclaim (fail closed)"],
"freshness": freshness,
}
status = str(freshness.get("status") or "")
if status in (STATUS_STALE_MISSED_HEARTBEAT, STATUS_STALE_ABSOLUTE_CAP):
# #790: under the heartbeat lifecycle the heartbeat *is* the liveness
# proof, so a session that stopped heartbeating past its grace has
# released its claim by definition. Requiring a dead PID on top of that
# would reinstate the original defect — the recorded PID is the shared
# daemon, which stays alive across every abandoned task it ever served.
#
# This band is unreachable for a legacy lease (AC-N8), so no lock
# written before this lifecycle can be reclaimed by this path.
return {
"reclaim_allowed": True,
"reasons": [
f"heartbeat-lifecycle lease is {status}: {freshness.get('reason')}"
],
"freshness": freshness,
"prior_branch": existing_lock.get("branch_name"),
"prior_worktree": existing_lock.get("worktree_path"),
"prior_pid": existing_lock.get("session_pid") or existing_lock.get("pid"),
"prior_task_session_id": lease_task_session_id(existing_lock) or None,
}
pid = existing_lock.get("session_pid")
if pid is None:
pid = existing_lock.get("pid")
@@ -440,9 +1009,19 @@ def assess_same_issue_lease_conflict(
branch_name: str,
worktree_path: str,
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
renewal_sanctioned: bool = False,
now: datetime | None = None,
) -> str | None:
"""Return a fail-closed error when a competing live lease blocks acquisition."""
"""Return a fail-closed error when a competing live lease blocks acquisition.
``renewal_sanctioned`` is set only when
``issue_lock_renewal.assess_exact_owner_lease_renewal`` has already proven,
from the durable lock plus live server-side observation, that this session
is the exact recorded owner of an *expired* lease (#760). It is never a
caller-supplied parameter of any MCP tool (#760 AC14): the server computes
it and passes it down. Left False, every pre-existing disposition is
unchanged.
"""
if not existing_lock:
return None
@@ -463,6 +1042,16 @@ def assess_same_issue_lease_conflict(
and _same_realpath(str(existing_worktree or ""), worktree_path)
)
if is_lease_expired(existing_lock, now=now):
# #760 AC1/AC2: exact-owner renewal is a different disposition from
# foreign takeover and is evaluated first. Before this, both branches
# below returned unconditionally, so the same_owner allowance further
# down was unreachable for every expired lease — an owner could never
# renew its own lock once the wall clock passed, no matter how complete
# its ownership evidence. Requires BOTH the locally recomputed
# same_owner match and the server-proven renewal waiver; either alone is
# insufficient.
if same_owner and renewal_sanctioned:
return None
reclaim = assess_expired_lock_reclaim(existing_lock, now=now)
if reclaim.get("reclaim_allowed"):
# #601: expired + dead pid / missing worktree may be reclaimed
+218 -4
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] = (),
@@ -93,6 +285,7 @@ def assess_issue_lock_worktree(
base_branch: str | None = None,
base_branches: frozenset[str] | None = None,
recovery_sanctioned: bool = False,
renewal_sanctioned: bool = False,
) -> dict:
"""Fail closed when lock preconditions are not met on the declared worktree.
@@ -104,6 +297,19 @@ def assess_issue_lock_worktree(
by construction and could never satisfy it. Every other precondition —
notably worktree cleanliness — still applies unchanged, and brand-new issue
claims keep the full base-equivalence requirement.
``renewal_sanctioned`` waives base-equivalence on exactly the same grounds
for the other proven-ownership case (#760): ``issue_lock_renewal`` has shown
that an *expired* lease is being renewed by its exact recorded owner — same
remote, org, repo, issue, operation, branch, realpath-normalized worktree,
claimant username and profile — with the local head matching the remote head
and any owning PR head. Such a branch carries committed work for the same
reason a recovered one does, so it can never be base-equivalent either.
Both waivers relax this one requirement and nothing else. Neither is
caller-supplied: each is computed server-side from durable lock state plus
live observation. With both False every precondition applies exactly as
before.
"""
bases = base_branches or BASE_BRANCHES
reasons: list[str] = []
@@ -122,9 +328,12 @@ def assess_issue_lock_worktree(
f"(dirty files: {', '.join(dirty_files)})"
)
if recovery_sanctioned:
if recovery_sanctioned or renewal_sanctioned:
# Base-equivalence intentionally not evaluated: ownership was proven
# against the durable lock record instead (#753).
# against the durable lock record instead — by dead-session recovery
# (#753) or by exact-owner renewal of an expired lease (#760). Every
# other precondition above and below still applies; cleanliness in
# particular is checked before this branch and is never waived.
pass
elif base_equivalent is False:
reasons.append(
@@ -155,6 +364,7 @@ def assess_issue_lock_worktree(
base_branch=base_branch,
base_equivalent=base_equivalent,
recovery_sanctioned=recovery_sanctioned,
renewal_sanctioned=renewal_sanctioned,
)
@@ -214,6 +424,7 @@ def _assessment(
base_branch: str | None = None,
base_equivalent: bool | None = None,
recovery_sanctioned: bool = False,
renewal_sanctioned: bool = False,
) -> dict:
return {
"proven": proven,
@@ -226,7 +437,10 @@ def _assessment(
"base_branch": base_branch,
"base_equivalent": base_equivalent,
"recovery_sanctioned": recovery_sanctioned,
"base_equivalence_waived": bool(recovery_sanctioned),
"renewal_sanctioned": renewal_sanctioned,
# Either proven-ownership waiver relaxes base-equivalence; the two are
# reported separately so an audit can tell which one applied.
"base_equivalence_waived": bool(recovery_sanctioned or renewal_sanctioned),
}
+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"
]
+212
View File
@@ -0,0 +1,212 @@
"""Central lease policy configuration (#790 Slice A, AC-N7).
The single authoritative source for every lease duration in the project. Before
this module the numbers were scattered: a four-hour author TTL was declared
twice (``issue_lock_store`` and ``gitea_mcp_server``), the reviewer/merger
sliding window lived in ``reviewer_pr_lease``, the conflict-fix window in
``pr_work_lease``, and the control-plane default in ``control_plane_db``.
Nothing tied them together, so tuning one class silently diverged from the
others and no reader could answer "how long does a lease live?" without
grepping four files.
AC-N7 requires that this configuration exist *before* the first heartbeat and
TTL behavior that reads from it, so it ships in Slice A rather than trailing the
code it governs.
Deliberate boundaries:
* **Declaration is not rewiring.** Every task class is declared here, but only
those with ``heartbeat_lifecycle_active`` were migrated onto the shared
heartbeat lifecycle in Slice A — currently ``author_issue_work`` alone.
Reviewer, merger, and conflict-fix leases keep their own existing behavior
until Slice C moves them; their numbers are recorded here so the two cannot
drift apart unnoticed, and ``tests/test_issue_790_lease_policy.py`` asserts
the recorded values still equal the constants those modules use.
* **No policy decision lives here.** This module answers "how long", never "may
this session proceed". Freshness, reclaim, and renewal dispositions stay in
``issue_lock_store``.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
# Task classes. Only the first is migrated onto the shared lifecycle in Slice A.
TASK_CLASS_AUTHOR_ISSUE_WORK = "author_issue_work"
TASK_CLASS_REVIEWER_PR = "reviewer_pr"
TASK_CLASS_MERGER_PR = "merger_pr"
TASK_CLASS_CONFLICT_FIX = "conflict_fix"
# Durable marker for a lease minted under the shared heartbeat lifecycle.
#
# #790 AC-N8: this explicit marker — never a timestamp comparison — is what
# distinguishes a heartbeat-lifecycle lease from a legacy one. A lock written
# before this lifecycle existed carries no marker and reads as
# ``LIFECYCLE_LEGACY``.
LIFECYCLE_HEARTBEAT_V1 = "heartbeat-v1"
LIFECYCLE_LEGACY = "legacy"
_ENV_PREFIX = "GITEA_LEASE_POLICY"
@dataclass(frozen=True)
class LeasePolicy:
"""Durations governing one task class.
All intervals are minutes except ``absolute_cap_hours``. ``None`` for the
cap means the class has no maximum continuous duration.
"""
task_class: str
initial_ttl_minutes: float
heartbeat_cadence_minutes: float
stale_warning_minutes: float
missed_heartbeat_grace_minutes: float
absolute_cap_hours: float | None
recovery_grace_minutes: float
terminal_race_drain_minutes: float
terminal_retirement_eligible: bool
heartbeat_lifecycle_active: bool
# Defaults. ``author_issue_work`` adopts the reviewer window proven by #747
# rather than inventing new numbers: a lease expires 10 minutes after its last
# valid heartbeat, warns at half that, and an actively heartbeating session is
# never evicted. The prior value was a fixed four hours (240 minutes) that no
# heartbeat could shorten — the defect this issue exists to correct.
_DEFAULTS: dict[str, LeasePolicy] = {
TASK_CLASS_AUTHOR_ISSUE_WORK: LeasePolicy(
task_class=TASK_CLASS_AUTHOR_ISSUE_WORK,
initial_ttl_minutes=10.0,
heartbeat_cadence_minutes=2.0,
stale_warning_minutes=5.0,
missed_heartbeat_grace_minutes=10.0,
absolute_cap_hours=8.0,
recovery_grace_minutes=10.0,
terminal_race_drain_minutes=2.0,
terminal_retirement_eligible=True,
heartbeat_lifecycle_active=True,
),
# Declared, not rewired. These mirror reviewer_pr_lease.LEASE_TTL_MINUTES
# and STALE_WARNING_MINUTES; Slice C migrates the call sites.
TASK_CLASS_REVIEWER_PR: LeasePolicy(
task_class=TASK_CLASS_REVIEWER_PR,
initial_ttl_minutes=10.0,
heartbeat_cadence_minutes=2.0,
stale_warning_minutes=5.0,
missed_heartbeat_grace_minutes=10.0,
absolute_cap_hours=None,
recovery_grace_minutes=10.0,
terminal_race_drain_minutes=2.0,
terminal_retirement_eligible=False,
heartbeat_lifecycle_active=False,
),
TASK_CLASS_MERGER_PR: LeasePolicy(
task_class=TASK_CLASS_MERGER_PR,
initial_ttl_minutes=10.0,
heartbeat_cadence_minutes=2.0,
stale_warning_minutes=5.0,
missed_heartbeat_grace_minutes=10.0,
absolute_cap_hours=None,
recovery_grace_minutes=10.0,
terminal_race_drain_minutes=2.0,
terminal_retirement_eligible=False,
heartbeat_lifecycle_active=False,
),
# Mirrors pr_work_lease.DEFAULT_CONFLICT_FIX_TTL_MINUTES. Deliberately left
# at its current window; shortening it is Slice C's call, not this slice's.
TASK_CLASS_CONFLICT_FIX: LeasePolicy(
task_class=TASK_CLASS_CONFLICT_FIX,
initial_ttl_minutes=120.0,
heartbeat_cadence_minutes=2.0,
stale_warning_minutes=5.0,
missed_heartbeat_grace_minutes=10.0,
absolute_cap_hours=None,
recovery_grace_minutes=10.0,
terminal_race_drain_minutes=2.0,
terminal_retirement_eligible=False,
heartbeat_lifecycle_active=False,
),
}
_NUMERIC_FIELDS = (
"initial_ttl_minutes",
"heartbeat_cadence_minutes",
"stale_warning_minutes",
"missed_heartbeat_grace_minutes",
"absolute_cap_hours",
"recovery_grace_minutes",
"terminal_race_drain_minutes",
)
def env_var_name(task_class: str, field: str) -> str:
"""Environment variable that overrides one field of one task class."""
return f"{_ENV_PREFIX}_{task_class.upper()}_{field.upper()}"
def _override(task_class: str, field: str, default: float | None) -> float | None:
"""Read one override, falling back to *default* on anything unusable.
A malformed or non-positive override is ignored rather than raised: a typo
in an environment variable must not be able to mint a zero-length lease that
makes every claim instantly reclaimable, nor crash the server at import.
"""
raw = (os.environ.get(env_var_name(task_class, field)) or "").strip()
if not raw:
return default
try:
value = float(raw)
except (TypeError, ValueError):
return default
if value <= 0:
return default
return value
def policy_for(task_class: str) -> LeasePolicy:
"""Return the effective policy for *task_class*.
Unknown task classes fall back to the author policy, which is the most
conservative migrated class, rather than raising — a new caller must never
be able to crash a lock write by naming a class this table has not learned.
"""
key = str(task_class or "").strip() or TASK_CLASS_AUTHOR_ISSUE_WORK
base = _DEFAULTS.get(key) or _DEFAULTS[TASK_CLASS_AUTHOR_ISSUE_WORK]
resolved = {
field: _override(base.task_class, field, getattr(base, field))
for field in _NUMERIC_FIELDS
}
if all(resolved[field] == getattr(base, field) for field in _NUMERIC_FIELDS):
return base
return LeasePolicy(
task_class=base.task_class,
terminal_retirement_eligible=base.terminal_retirement_eligible,
heartbeat_lifecycle_active=base.heartbeat_lifecycle_active,
**resolved,
)
def known_task_classes() -> tuple[str, ...]:
"""Every declared task class, migrated or not."""
return tuple(_DEFAULTS)
def describe(task_class: str) -> dict[str, Any]:
"""Serializable view of a policy, for audit records and tool payloads."""
policy = policy_for(task_class)
return {
"task_class": policy.task_class,
"initial_ttl_minutes": policy.initial_ttl_minutes,
"heartbeat_cadence_minutes": policy.heartbeat_cadence_minutes,
"stale_warning_minutes": policy.stale_warning_minutes,
"missed_heartbeat_grace_minutes": policy.missed_heartbeat_grace_minutes,
"absolute_cap_hours": policy.absolute_cap_hours,
"recovery_grace_minutes": policy.recovery_grace_minutes,
"terminal_race_drain_minutes": policy.terminal_race_drain_minutes,
"terminal_retirement_eligible": policy.terminal_retirement_eligible,
"heartbeat_lifecycle_active": policy.heartbeat_lifecycle_active,
"lifecycle_version": LIFECYCLE_HEARTBEAT_V1,
}
+213 -17
View File
@@ -24,12 +24,50 @@ from __future__ import annotations
import os
import subprocess
import time
# Live-remote head cache: the parity gate runs on every mutation and every
# runtime-context read, so the ``git ls-remote`` result is cached briefly to
# avoid a network round-trip per call (#610). Keyed by (root, remote, branch).
_REMOTE_HEAD_CACHE: dict[tuple[str, str, str], tuple[float, str | None]] = {}
_REMOTE_HEAD_TTL = 60.0
# When True, ``read_remote_master_head`` never performs ``git ls-remote`` unless
# ``GITEA_TEST_LIVE_REMOTE_HEAD`` is set. Conftest enables this suite-wide so
# feature worktrees (whose HEAD differs from live master) cannot flip legacy
# runtime-context assertions to live_stale, and so unit tests never depend on
# a live network (PR #788 F1/F2 / issue #610). Module-level (not env-only) so
# ``patch.dict(os.environ, …, clear=True)`` cannot re-enable the probe.
_HERMETIC_TEST_MODE: bool = False
def _clear_remote_head_cache() -> None:
"""Reset the live-remote head cache (test isolation / forced refresh)."""
_REMOTE_HEAD_CACHE.clear()
def set_hermetic_test_mode(enabled: bool) -> None:
"""Enable or disable suite-wide hermetic live-remote reads (tests only)."""
global _HERMETIC_TEST_MODE
_HERMETIC_TEST_MODE = bool(enabled)
_clear_remote_head_cache()
def hermetic_test_mode() -> bool:
"""Return whether hermetic live-remote reads are active."""
return bool(_HERMETIC_TEST_MODE)
# Environment escape hatches (ops + tests):
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
# GITEA_TEST_LIVE_REMOTE_HEAD -> force the live remote master read, for tests.
ENV_TEST_LIVE_REMOTE_HEAD = "GITEA_TEST_LIVE_REMOTE_HEAD"
# GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE -> opt a single test into a real ls-remote
# even when hermetic mode is on (rare; prefer ENV_TEST_LIVE_REMOTE_HEAD).
ENV_TEST_ALLOW_LIVE_REMOTE_PROBE = "GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE"
def read_git_head(root: str) -> str | None:
@@ -58,6 +96,75 @@ def read_git_head(root: str) -> str | None:
return (res.stdout or "").strip() or None
def read_remote_master_head(
root: str,
remote: str = "origin",
branch: str = "master",
ttl: float = _REMOTE_HEAD_TTL,
) -> str | None:
"""Return the live remote ``branch`` commit SHA, or ``None`` (#610).
Resolves the *live* target commit via ``git ls-remote`` so parity can tell
a daemon that is behind the live remote master apart from one whose local
checkout simply hasn't been pulled. ``None`` means the live head could not
be resolved (offline, no such remote, git unavailable, error) -- callers
must treat unknown live state as *not mutation-safe* while never blocking
read-only diagnostics. A ``GITEA_TEST_LIVE_REMOTE_HEAD`` override takes
precedence so the wiring can be exercised deterministically and offline.
The result is cached for *ttl* seconds per (root, remote, branch) so the
gate does not run a network probe on every mutation/read (``ttl=0`` forces
a live probe). Both hits and ``None`` misses are cached to bound offline
latency; the env override bypasses the cache and the subprocess entirely.
Under suite hermetic mode (``set_hermetic_test_mode(True)``, set by
conftest) a missing override returns ``None`` without network I/O so
feature-worktree test runs cannot observe live_stale against real master
(PR #788 F1) and unit tests stay offline (F2). Opt out with an explicit
``GITEA_TEST_LIVE_REMOTE_HEAD`` pin or ``GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE``.
"""
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
if forced is not None:
return forced.strip() or None
if _HERMETIC_TEST_MODE and not (
os.environ.get(ENV_TEST_ALLOW_LIVE_REMOTE_PROBE) or ""
).strip():
# Hermetic default: live head unknown. live_stale stays False;
# mutation_safe is False when live is unknown (documented #610 note).
return None
# Defense in depth: even without the module flag, never probe while pytest
# is running unless the test opted into a real probe or set an override.
if (os.environ.get("PYTEST_CURRENT_TEST") or "").strip() and not (
os.environ.get(ENV_TEST_ALLOW_LIVE_REMOTE_PROBE) or ""
).strip():
return None
if not root:
return None
key = (root, remote, branch)
now = time.monotonic()
if ttl > 0:
cached = _REMOTE_HEAD_CACHE.get(key)
if cached is not None and (now - cached[0]) < ttl:
return cached[1]
sha: str | None = None
try:
res = subprocess.run(
["git", "-C", root, "ls-remote", remote, f"refs/heads/{branch}"],
capture_output=True,
text=True,
check=False,
timeout=5,
)
if res.returncode == 0:
lines = (res.stdout or "").strip().splitlines()
if lines:
sha = lines[0].split("\t", 1)[0].split()[0].strip() or None
except Exception:
sha = None
_REMOTE_HEAD_CACHE[key] = (now, sha)
return sha
def capture_startup_parity(root: str, head: str | None = None) -> dict:
"""Capture the process source-tree baseline once at server startup.
@@ -72,18 +179,38 @@ def _short(sha: str | None) -> str:
return sha[:12] if sha else "unknown"
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
def assess_master_parity(
startup: dict | None,
current_head: str | None,
live_remote_head: str | None = None,
) -> dict:
"""Compare the startup baseline against the current on-disk ``HEAD``.
Pure: both HEADs are supplied by the caller. Returns a structured result:
Pure: all HEADs are supplied by the caller. Returns a structured result:
- ``in_parity`` -- server code matches the on-disk master (or parity
could not be determined, which is not treated as stale).
- ``stale`` -- the on-disk master has definitively advanced past the
running process.
- ``restart_required`` -- alias of ``stale``; the recovery action.
- ``determinable`` -- whether both HEADs were known well enough to compare.
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
- ``determinable`` -- whether both local HEADs were known well enough to
compare.
- ``startup_head`` / ``current_head`` / ``reasons``.
#610 adds live-remote awareness so a daemon that is stale relative to the
*live* remote master cannot report a mutation-safe result even when the
local checkout HEAD still matches the daemon's startup commit:
- ``daemon_start_head`` -- the commit the running process started at
(alias of ``startup_head``, named for clarity in reports).
- ``local_head`` -- the on-disk checkout HEAD (alias of ``current_head``).
- ``live_remote_head`` -- the live remote target commit, or ``None`` when it
could not be fetched.
- ``live_known`` -- whether the live remote target was resolved.
- ``live_stale`` -- the live remote master has advanced past the running
process (daemon is behind live master) even if local parity is green.
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
target all agree; the only state in which a mutation may rely on parity.
"""
startup_head = (startup or {}).get("startup_head")
reasons: list[str] = []
@@ -91,32 +218,56 @@ def assess_master_parity(startup: dict | None, current_head: str | None) -> dict
if startup_head is None:
reasons.append(
"startup commit was not captured; code parity cannot be enforced")
return _result(True, False, False, startup_head, current_head, reasons)
return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons)
if current_head is None:
reasons.append(
"current workspace HEAD could not be read; code parity cannot be "
"enforced")
return _result(True, False, False, startup_head, current_head, reasons)
return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons)
if startup_head == current_head:
return _result(True, False, True, startup_head, current_head, reasons)
local_in_parity = startup_head == current_head
local_stale = not local_in_parity
if local_stale:
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the "
f"workspace master is now {_short(current_head)}; restart the "
f"server to load the current capability gates")
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the workspace "
f"master is now {_short(current_head)}; restart the server to load the "
f"current capability gates")
return _result(False, True, True, startup_head, current_head, reasons)
live_known = live_remote_head is not None
live_stale = live_known and live_remote_head != startup_head
if live_stale:
reasons.append(
f"live remote master is {_short(live_remote_head)} but the MCP "
f"server started at {_short(startup_head)}; the daemon is stale "
f"relative to live master -- restart/reconnect before mutating")
return _result(
local_in_parity, local_stale, True, startup_head, current_head,
live_remote_head, live_stale, reasons)
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
def _result(in_parity, stale, determinable, startup_head, current_head,
live_remote_head, live_stale, reasons):
live_known = live_remote_head is not None
mutation_safe = (
determinable and in_parity and live_known and not live_stale)
return {
"in_parity": in_parity,
"stale": stale,
"restart_required": stale,
"restart_required": stale or live_stale,
"determinable": determinable,
"startup_head": startup_head,
"current_head": current_head,
# #610 distinguished signals:
"daemon_start_head": startup_head,
"local_head": current_head,
"live_remote_head": live_remote_head,
"live_known": live_known,
"live_stale": live_stale,
"mutation_safe": mutation_safe,
"reasons": list(reasons),
}
@@ -130,11 +281,13 @@ def parity_block_reasons(assessment: dict) -> list[str]:
"""Block reasons for a mutation gate (empty when the mutation may proceed).
A disabled gate or an in-parity / non-determinable assessment yields no
reasons; only a definitively stale server blocks.
reasons. A definitively stale server blocks, and (#610) a daemon that is
stale relative to the *live* remote master blocks even when the local
checkout HEAD still matches the daemon's startup commit.
"""
if gate_disabled():
return []
if assessment.get("stale"):
if assessment.get("stale") or assessment.get("live_stale"):
return list(assessment.get("reasons") or
["server code is stale relative to master (fail closed)"])
return []
@@ -147,6 +300,10 @@ def parity_report(assessment: dict) -> dict:
"restart_required": True,
"startup_head": assessment.get("startup_head"),
"current_head": assessment.get("current_head"),
# #610: name the live remote target so the report distinguishes a
# local-code stale from a daemon-behind-live-master stale.
"live_remote_head": assessment.get("live_remote_head"),
"live_stale": bool(assessment.get("live_stale")),
"reasons": list(assessment.get("reasons") or []),
"recovery": [
"The running MCP server is executing code older than the current "
@@ -157,6 +314,45 @@ def parity_report(assessment: dict) -> dict:
}
def parity_resolver_disagreement(
assessment: dict,
resolver_restart_required: bool,
) -> dict | None:
"""Typed blocker when the resolver requires restart but parity looks green.
The capability resolver (``gitea_resolve_task_capability``) detects stale
runtime authoritatively for mutation safety (#610). When it requires a
restart, local-only parity must never override it: this returns a typed,
fail-closed blocker that names the resolver as authoritative. Returns
``None`` when the resolver does not require a restart.
"""
if not resolver_restart_required:
return None
parity_optimistic = bool(assessment.get("in_parity")) and not (
assessment.get("stale") or assessment.get("live_stale"))
return {
"kind": "parity_resolver_disagreement",
"restart_required": True,
"resolver_authoritative": True,
"parity_optimistic": parity_optimistic,
"daemon_start_head": assessment.get("daemon_start_head"),
"local_head": assessment.get("local_head"),
"live_remote_head": assessment.get("live_remote_head"),
"reasons": [
"The capability resolver requires a restart/reconnect (stale "
"runtime) but master-parity reported local code as in-parity. "
"The resolver is authoritative for mutation safety; do not mutate "
"on local parity alone. Restart/reconnect the Gitea MCP server "
"and re-verify before mutating.",
],
"recovery": [
"Trust the resolver: treat this session as stale.",
"Restart or /mcp reconnect the Gitea MCP namespace so it reloads "
"current master and live target state, then re-run preflight.",
],
}
def format_parity(assessment: dict) -> str:
"""One-line human summary for logs / runtime context."""
if assessment.get("stale"):
+43 -15
View File
@@ -19,6 +19,32 @@ print_banner() {
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
}
show_workflow_dashboard_help() {
printf '\n--- Workflow dashboard (queue, leases, next safe action) ---\n\n'
printf 'Read-only operational view (#605). Does NOT assign work.\n'
printf 'Exclusive assignment still requires gitea_allocate_next_work.\n\n'
printf 'Canonical MCP tool (any healthy Gitea namespace with gitea.read):\n\n'
printf ' gitea_workflow_dashboard(\n'
printf ' remote=\"prgs\",\n'
printf ' org=\"Scaled-Tech-Consulting\",\n'
printf ' repo=\"Gitea-Tools\",\n'
printf ' )\n\n'
printf 'Returns machine-readable sections:\n'
printf ' - open_pr_queue / open_issue_queue\n'
printf ' - active_leases_by_role / stale_or_expired_leases\n'
printf ' - terminal_review_lock\n'
printf ' - blocked_items (never presented as safe)\n'
printf ' - review_ready_prs / merge_ready_prs / author_remediation\n'
printf ' - discussion_issues / controller_needed\n'
printf ' - next_safe_by_role + primary_next_safe_action with exact prompts\n'
printf ' - human_summary (copy-friendly multi-line text)\n\n'
printf 'Safety:\n'
printf ' - Never suggests blocked or terminal-locked items as safe.\n'
printf ' - Incomplete inventory fails closed (no safe suggestions).\n'
printf ' - This menu entry is documentation only; it does not call Gitea.\n'
pause
}
show_root_checkout_health() {
printf '\n--- Project status / root checkout health ---\n\n'
printf 'Current directory: %s\n' "$(pwd)"
@@ -241,25 +267,27 @@ main_menu() {
while true; do
print_banner
printf ' 1) Project status / root checkout health\n'
printf ' 2) Author workflow prompts\n'
printf ' 3) Reviewer workflow prompts\n'
printf ' 4) Merger workflow prompts\n'
printf ' 5) Reconciler workflow prompts\n'
printf ' 6) Onboarding new project to this MCP workflow\n'
printf ' 7) Proxmox deployment menu placeholder\n'
printf ' 8) Create Proxmox LXC placeholder\n'
printf ' 9) Run tests\n'
printf ' 2) Workflow dashboard (queue, leases, next safe action)\n'
printf ' 3) Author workflow prompts\n'
printf ' 4) Reviewer workflow prompts\n'
printf ' 5) Merger workflow prompts\n'
printf ' 6) Reconciler workflow prompts\n'
printf ' 7) Onboarding new project to this MCP workflow\n'
printf ' 8) Proxmox deployment menu placeholder\n'
printf ' 9) Create Proxmox LXC placeholder\n'
printf ' t) Run tests\n'
printf ' 0) Exit\n'
read -r -p 'Choice: ' choice
case "$choice" in
1) show_root_checkout_health ;;
2) show_author_prompts ;;
3) show_reviewer_prompts ;;
4) show_merger_prompts ;;
5) show_reconciler_prompts ;;
6) show_onboarding_prompt ;;
7|8) show_proxmox_placeholder ;;
9) run_tests ;;
2) show_workflow_dashboard_help ;;
3) show_author_prompts ;;
4) show_reviewer_prompts ;;
5) show_merger_prompts ;;
6) show_reconciler_prompts ;;
7) show_onboarding_prompt ;;
8|9) show_proxmox_placeholder ;;
t|T|tests) run_tests ;;
0) printf 'Goodbye.\n'; exit 0 ;;
*) printf 'Invalid choice.\n'; pause ;;
esac
+9
View File
@@ -36,6 +36,11 @@ KIND_REVIEW_DRAFT = "review_draft"
# other session proofs; a contaminated session fails closed on gated mutations
# until a reconciler audits and clears it.
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
# Durable marker set when a worker session manually kills MCP daemon processes
# instead of using a sanctioned reconnect/restart path (#630). Same shape and
# same reconciler-only clear as the #671 marker above; kept as its own kind so
# an audit can tell the two contamination classes apart.
KIND_RUNTIME_RECOVERY_CONTAMINATION = "runtime_recovery_contamination"
# Durable shadow of the in-memory reviewer session lease (#702). Written on
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
# daemon that dies without teardown leaves provable orphan evidence (owner
@@ -71,6 +76,10 @@ RECOVERY_CRITICAL_KINDS = frozenset(
# #702 crash-orphan evidence (must outlive TTL; F4)
KIND_REVIEWER_SESSION_LEASE,
KIND_STALE_BINDING_RECOVERY,
# #630: contamination must not expire into cleanliness. A TTL-bound
# marker would let a contaminated session self-clear by waiting, which
# defeats the reconciler-only clear the gate depends on.
KIND_RUNTIME_RECOVERY_CONTAMINATION,
}
)
+195
View File
@@ -0,0 +1,195 @@
"""Documented-vs-registered MCP tool inventory guard (#781).
The workflow documentation named a ``gitea_edit_issue`` tool that no namespace
had ever registered. Nothing compared the two lists, so an actor could plan a
mutation against a tool that did not exist and only discover it at execution
time — after the work was already scoped around it.
This module is that comparison, in two directions:
- :func:`assess_inventory_drift` compares the canonical inventory documented in
``docs/mcp-tool-inventory.md`` against the tools actually registered on the
MCP server. Either list drifting fails the guard, so a new tool must be
documented and a removed tool must be undocumented in the same change.
- :func:`assess_doc_references` catches the original defect directly: any tool
name a workflow/skill document tells an actor to call must be registered.
Module and script names legitimately appear in the same prose (``gitea_auth``,
``offline_mcp_runner``), so :data:`NON_TOOL_IDENTIFIERS` names the known
non-tool identifiers explicitly rather than loosening the pattern.
This module performs no I/O — callers own reading the files.
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Mapping
#: Canonical documented inventory, relative to the repository root.
INVENTORY_DOC_PATH = "docs/mcp-tool-inventory.md"
#: Delimiters around the generated inventory list in the doc.
INVENTORY_BEGIN_MARKER = "<!-- BEGIN REGISTERED TOOL INVENTORY -->"
INVENTORY_END_MARKER = "<!-- END REGISTERED TOOL INVENTORY -->"
#: Backticked identifiers that look like tool names but are modules/scripts.
#: Every entry is a real file in this repository, not an MCP tool.
NON_TOOL_IDENTIFIERS: frozenset[str] = frozenset(
{
"gitea_auth",
"gitea_config",
"gitea_mcp_server",
"mcp_server",
"offline_mcp_helper",
"offline_mcp_runner",
}
)
#: Prefixes that mark an identifier as a candidate MCP tool name.
TOOL_NAME_PREFIXES: tuple[str, ...] = ("gitea_", "mcp_")
_INVENTORY_ENTRY = re.compile(r"^-\s+`([A-Za-z_][A-Za-z0-9_]*)`")
_BACKTICKED = re.compile(r"`([A-Za-z_][A-Za-z0-9_]*)`")
def parse_documented_inventory(text: str) -> list[str]:
"""Return the tool names listed between the inventory markers.
Raises ``ValueError`` when the markers are missing or out of order, so a
mangled document fails the guard instead of silently documenting nothing.
"""
start = text.find(INVENTORY_BEGIN_MARKER)
end = text.find(INVENTORY_END_MARKER)
if start == -1 or end == -1 or end < start:
raise ValueError(
f"{INVENTORY_DOC_PATH} must contain "
f"'{INVENTORY_BEGIN_MARKER}' followed by "
f"'{INVENTORY_END_MARKER}' (fail closed)."
)
block = text[start + len(INVENTORY_BEGIN_MARKER) : end]
names: list[str] = []
for line in block.splitlines():
match = _INVENTORY_ENTRY.match(line.strip())
if match:
names.append(match.group(1))
return names
def looks_like_tool_name(identifier: str) -> bool:
"""Return whether a backticked identifier is a candidate tool name."""
if identifier in NON_TOOL_IDENTIFIERS:
return False
return identifier.startswith(TOOL_NAME_PREFIXES)
def extract_tool_references(text: str) -> set[str]:
"""Return candidate tool names a document tells an actor to call."""
return {
name
for name in _BACKTICKED.findall(text)
if looks_like_tool_name(name)
}
def assess_inventory_drift(
documented: Iterable[str],
registered: Iterable[str],
) -> dict[str, Any]:
"""Compare the documented inventory against the registered tool set."""
documented_list = list(documented)
documented_set = set(documented_list)
registered_set = set(registered)
duplicates = sorted(
{name for name in documented_list if documented_list.count(name) > 1}
)
documented_not_registered = sorted(documented_set - registered_set)
registered_not_documented = sorted(registered_set - documented_set)
unsorted = documented_list != sorted(documented_list)
reasons: list[str] = []
if documented_not_registered:
reasons.append(
"documented but not registered: "
+ ", ".join(documented_not_registered)
)
if registered_not_documented:
reasons.append(
"registered but not documented: "
+ ", ".join(registered_not_documented)
)
if duplicates:
reasons.append("listed more than once: " + ", ".join(duplicates))
if unsorted:
reasons.append("inventory entries are not in sorted order")
in_sync = not reasons
return {
"in_sync": in_sync,
"documented_count": len(documented_set),
"registered_count": len(registered_set),
"documented_not_registered": documented_not_registered,
"registered_not_documented": registered_not_documented,
"duplicates": duplicates,
"sorted": not unsorted,
"reasons": reasons,
"safe_next_action": (
""
if in_sync
else (
f"Update {INVENTORY_DOC_PATH} so the block between the "
"inventory markers lists exactly the registered tools, sorted, "
"one '- `tool_name`' entry per line."
)
),
}
def assess_doc_references(
references: Mapping[str, Iterable[str]],
registered: Iterable[str],
) -> dict[str, Any]:
"""Verify every tool a document names is actually registered.
*references* maps a document path to the candidate tool names it mentions.
"""
registered_set = set(registered)
unregistered: list[dict[str, Any]] = []
checked = 0
for path, names in sorted(references.items()):
for name in sorted(set(names)):
checked += 1
if name not in registered_set:
unregistered.append({"document": path, "tool": name})
clean = not unregistered
reasons = [
f"{entry['document']} documents '{entry['tool']}', "
"which no namespace registers"
for entry in unregistered
]
return {
"clean": clean,
"checked_count": checked,
"unregistered": unregistered,
"reasons": reasons,
"safe_next_action": (
""
if clean
else (
"Either register the named tool with @mcp.tool() or correct the "
"document. Documentation must never name a tool an actor cannot "
"reach. If the identifier is a module or script rather than a "
"tool, add it to mcp_tool_inventory.NON_TOOL_IDENTIFIERS."
)
),
}
def render_inventory_block(registered: Iterable[str]) -> str:
"""Render the marker-delimited inventory block for the documentation."""
lines = [INVENTORY_BEGIN_MARKER, ""]
lines.extend(f"- `{name}`" for name in sorted(set(registered)))
lines.extend(["", INVENTORY_END_MARKER])
return "\n".join(lines)
+286
View File
@@ -0,0 +1,286 @@
"""Mutation-budget classification for auto-mode attempts (#617).
Mutation budget must count only *server-side* Gitea state changes. A tool call
that fails closed before the Gitea API is reached changed nothing on the
server, so it must not consume the budget that protects against repeated real
mutations.
The classifier separates four outcome classes plus an explicit ambiguous class:
``local_validator_rejection``
A canonical-content validator (for example the ``[THREAD STATE LEDGER]`` or
``## Canonical Issue State`` blocks) rejected the payload before any API
call. No server-side state exists.
``capability_gate_rejection``
A profile/permission gate refused the operation before any API call.
``transport_failure_before_api``
The request never reached the Gitea API (transport/EOF/connection error).
``server_side_mutation``
The API succeeded and returned proof of durable state (comment id, review
id, merge commit, label result, or an issue/PR state change).
``ambiguous_requires_readback``
The API *was* reached but the result carries no usable proof either way.
This fails closed: the attempt is treated as budget-consuming until a
read-after-write check proves otherwise, so #617 never weakens the guard
that prevents repeated real mutations.
Only ``server_side_mutation`` consumes budget outright. Every attempt — failed
or not — is still recorded in the local attempt ledger so a final report can
show local failed attempts, blocked API attempts, and successful server-side
mutations separately.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
LOCAL_VALIDATOR_REJECTION = "local_validator_rejection"
CAPABILITY_GATE_REJECTION = "capability_gate_rejection"
TRANSPORT_FAILURE_BEFORE_API = "transport_failure_before_api"
SERVER_SIDE_MUTATION = "server_side_mutation"
AMBIGUOUS_REQUIRES_READBACK = "ambiguous_requires_readback"
CLASSIFICATIONS = (
LOCAL_VALIDATOR_REJECTION,
CAPABILITY_GATE_REJECTION,
TRANSPORT_FAILURE_BEFORE_API,
SERVER_SIDE_MUTATION,
AMBIGUOUS_REQUIRES_READBACK,
)
#: Result fields that prove durable server-side state was created.
MUTATION_PROOF_FIELDS = (
"comment_id",
"review_id",
"merge_commit_sha",
"label_result",
"state_change",
"created_pr_number",
)
#: Classes that never consume server-side mutation budget.
PRE_API_CLASSIFICATIONS = (
LOCAL_VALIDATOR_REJECTION,
CAPABILITY_GATE_REJECTION,
TRANSPORT_FAILURE_BEFORE_API,
)
FINAL_REPORT_REQUIRED_FIELDS = (
"local_failed_attempts",
"blocked_api_attempts",
"successful_server_mutations",
)
def _clean(value: Any) -> str:
return (value or "").strip() if isinstance(value, str) else str(value or "").strip()
def _proof_fields_present(result: dict) -> list[str]:
"""Return the mutation-proof fields carrying a usable value."""
present: list[str] = []
for field in MUTATION_PROOF_FIELDS:
value = result.get(field)
if value is None or value is False:
continue
if isinstance(value, str) and not value.strip():
continue
present.append(field)
return present
def _decision(
classification: str,
*,
budget_consumed: bool,
requires_readback: bool,
reasons: list[str],
proof_fields: list[str],
api_called: bool | None,
) -> dict:
return {
"classification": classification,
"budget_consumed": budget_consumed,
"requires_readback": requires_readback,
"pre_api": classification in PRE_API_CLASSIFICATIONS,
"api_called": api_called,
"proof_fields": proof_fields,
"reasons": reasons,
}
def classify_mutation_attempt(result: dict | None) -> dict:
"""Classify one mutation attempt and decide whether it consumes budget.
``result`` is the raw dict a Gitea MCP tool returned. The caller does not
pre-interpret it: classification is driven by the explicit ``api_called``
signal plus the proof fields the tool reports.
"""
data = dict(result or {})
success = bool(data.get("success"))
proof_fields = _proof_fields_present(data)
api_called = data.get("api_called")
# An unambiguous success carrying durable proof is a real mutation however
# the attempt was labelled upstream.
if success and proof_fields:
return _decision(
SERVER_SIDE_MUTATION,
budget_consumed=True,
requires_readback=False,
reasons=[
"API reported success with durable proof field(s): "
+ ", ".join(proof_fields)
],
proof_fields=proof_fields,
api_called=True,
)
if api_called is False:
# Nothing reached the server; pick the precise pre-API class.
if data.get("transport_error") or data.get("transport_failed"):
return _decision(
TRANSPORT_FAILURE_BEFORE_API,
budget_consumed=False,
requires_readback=False,
reasons=["transport failed before the Gitea API was reached"],
proof_fields=[],
api_called=False,
)
if data.get("permission_report") or data.get("capability_blocked"):
return _decision(
CAPABILITY_GATE_REJECTION,
budget_consumed=False,
requires_readback=False,
reasons=["capability/permission gate refused before any API call"],
proof_fields=[],
api_called=False,
)
return _decision(
LOCAL_VALIDATOR_REJECTION,
budget_consumed=False,
requires_readback=False,
reasons=[
"local validator rejected the payload before any API call; "
"no server-side state was created"
],
proof_fields=[],
api_called=False,
)
if api_called is True:
if success:
reason = (
"API reported success but returned no durable proof field; "
"read-after-write verification required before counting budget"
)
else:
reason = (
"API was reached and the outcome carries no durable proof; "
"read-after-write verification required before counting budget"
)
return _decision(
AMBIGUOUS_REQUIRES_READBACK,
budget_consumed=True,
requires_readback=True,
reasons=[reason],
proof_fields=proof_fields,
api_called=True,
)
# ``api_called`` was not reported at all. Fail closed rather than assuming
# nothing happened.
return _decision(
AMBIGUOUS_REQUIRES_READBACK,
budget_consumed=True,
requires_readback=True,
reasons=[
"attempt did not report 'api_called'; cannot prove the request "
"stopped before the Gitea API, so the attempt fails closed"
],
proof_fields=proof_fields,
api_called=None,
)
def record_attempt(
ledger: list[dict] | None,
result: dict | None,
*,
operation: str = "",
timestamp: str | None = None,
) -> dict:
"""Append one classified attempt to the local ledger and return the entry.
Every attempt is recorded, including the ones that consume no budget: the
point of #617 is that failed local attempts stay visible without being
miscounted as Gitea mutations.
"""
entries = ledger if isinstance(ledger, list) else []
entry = {
"operation": _clean(operation),
"timestamp": _clean(timestamp) or datetime.now(timezone.utc).isoformat(),
**classify_mutation_attempt(result),
}
entries.append(entry)
return entry
def summarize_attempt_ledger(ledger: list[dict] | None) -> dict:
"""Summarize a ledger into the categories a final report must show."""
entries = [e for e in (ledger or []) if isinstance(e, dict)]
def _count(*classifications: str) -> int:
return sum(1 for e in entries if e.get("classification") in classifications)
return {
"total_attempts": len(entries),
"local_failed_attempts": _count(LOCAL_VALIDATOR_REJECTION),
"blocked_api_attempts": _count(
CAPABILITY_GATE_REJECTION, TRANSPORT_FAILURE_BEFORE_API
),
"successful_server_mutations": _count(SERVER_SIDE_MUTATION),
"ambiguous_attempts": _count(AMBIGUOUS_REQUIRES_READBACK),
"budget_consumed": sum(1 for e in entries if e.get("budget_consumed")),
"requires_readback": any(e.get("requires_readback") for e in entries),
"entries": entries,
}
def assess_final_report_mutation_accounting(
report: dict | None,
ledger: list[dict] | None,
) -> dict:
"""Fail closed when a report's mutation accounting contradicts the ledger."""
data = dict(report or {})
summary = summarize_attempt_ledger(ledger)
reasons: list[str] = []
for field in FINAL_REPORT_REQUIRED_FIELDS:
if field not in data:
reasons.append(f"final report omits required field '{field}'")
continue
claimed = data.get(field)
actual = summary[field]
if claimed != actual:
reasons.append(
f"final report claims {field}={claimed} but the attempt ledger "
f"shows {actual}"
)
if summary["requires_readback"] and not data.get("readback_verified"):
reasons.append(
"ledger contains an ambiguous attempt; final report must record "
"'readback_verified' proof before claiming mutation accounting"
)
return {
"valid": not reasons,
"reasons": reasons,
"ledger_summary": {k: v for k, v in summary.items() if k != "entries"},
}
+176 -44
View File
@@ -24,7 +24,12 @@ ROLE_WORKTREE_ENVS: dict[str, str] = {
"reconciler": RECONCILER_WORKTREE_ENV,
}
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
# Controller has no task worktree env — it routes only (#840).
KNOWN_ROLE_KINDS = frozenset(
{"author", "reviewer", "merger", "reconciler", "controller"}
)
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler", "controller"})
def normalize_role_kind(
@@ -37,8 +42,12 @@ def normalize_role_kind(
profile = (profile_name or "").strip().lower()
if role == "reviewer" and "merger" in profile:
return "merger"
if "controller" in profile or role == "controller":
return "controller"
if role in ROLE_WORKTREE_ENVS:
return role
if role in KNOWN_ROLE_KINDS:
return role
return "author"
@@ -55,34 +64,77 @@ 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.
"""
env_map = env if env is not None else os.environ
role = normalize_role_kind(role_kind, profile_name=profile_name)
role_env_key = ROLE_WORKTREE_ENVS[role]
role_env_key = ROLE_WORKTREE_ENVS.get(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
role_env_candidate = (
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable", True)
if role_env_key
else (None, "no role worktree env", True)
)
for candidate, source, env_sourced in (
(worktree_path, "worktree_path argument", False),
(worktree, "worktree argument", False),
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
(_env_value(env_map, role_env_key),
f"{role_env_key} environment variable", True),
role_env_candidate,
(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 +159,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 +172,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 +227,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 +236,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 +323,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 +360,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 +383,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 +396,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 +421,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
@@ -321,7 +446,8 @@ def assess_namespace_mutation_workspace(
reasons.append(
f"{role} mutation blocked: workspace is the stable control checkout; "
f"create or reconnect to a session-owned worktree under branches/ "
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
f"{ACTIVE_WORKTREE_ENV}"
)
elif (
role in {"reviewer", "merger"}
@@ -345,4 +471,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"),
}
+35
View File
@@ -81,6 +81,12 @@ AUTHOR_TASKS = frozenset({
"reconcile_landed_pr",
})
CONTROLLER_TASKS = frozenset({
"process_work_queue",
"process-work-queue",
"cross_role_allocate",
})
RECONCILER_TASKS = frozenset({
"cleanup_merged_pr_branch",
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
@@ -132,6 +138,10 @@ TASK_REQUIRED_ROLE = {
"reconcile_close_superseded_pr": "reconciler",
"reconcile_close_satisfied_issue": "reconciler",
"reconcile_create_followup_issue": "reconciler",
# #840: controller-owned generic queue allocation / routing.
"process_work_queue": "controller",
"process-work-queue": "controller",
"cross_role_allocate": "controller",
}
WRONG_ROLE_REVIEWER_MSG = (
@@ -147,6 +157,10 @@ WRONG_ROLE_MERGER_MSG = (
"Wrong role/session for merger task. Launch merger MCP namespace."
)
WRONG_ROLE_CONTROLLER_MSG = (
"Wrong role/session for controller task. Launch controller MCP namespace."
)
_session_last_route: dict | None = None
@@ -281,6 +295,27 @@ def route_task_session(
_record_route(result)
return result
if required_role == "controller":
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_WRONG_ROLE,
"downstream_allowed": False,
"reasons": [
WRONG_ROLE_CONTROLLER_MSG,
"Controller tasks (process_work_queue / cross-role allocate) "
"cannot run in author, reviewer, merger, or reconciler "
"worker sessions.",
],
"message": WRONG_ROLE_CONTROLLER_MSG,
"runtime_switching_supported": runtime_switching_supported,
"profile_switch_blocked": not runtime_switching_supported,
}
_record_route(result)
return result
if required_role == "author":
route = ROUTE_TO_AUTHOR
message = (
+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,
}
+119 -2
View File
@@ -32,10 +32,33 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.issue.comment",
"role": "author",
},
# #790 Slice A: prove an owned author lease is still active. Strictly
# narrower than lock_issue — it can only slide a lease this exact session
# already owns, never acquire, take over, or revive one — so it gates on the
# same authority rather than introducing an operation name that every
# already-configured author profile would be missing.
"heartbeat_issue_lock": {
"permission": "gitea.issue.comment",
"role": "author",
},
"set_issue_labels": {
"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",
@@ -48,6 +71,14 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.branch.push",
"role": "author",
},
# #812 AC20: publish an already-committed, unpublished local head so
# exact-owner lease renewal has an observable remote head to reason about.
# Same authority as any other author push — deliberately not a new
# operation name, so it cannot widen an already-configured author profile.
"publish_unpublished_branch": {
"permission": "gitea.branch.push",
"role": "author",
},
"create_pr": {
"permission": "gitea.pr.create",
"role": "author",
@@ -287,8 +318,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.create",
"role": "author",
},
# #600: controller-owned allocator — any authenticated profile may call;
# routing enforces role match to selected work. Uses control-plane DB (#613).
# #600: workers and controller may call with gitea.read; role-scoped workers
# pass role=author|reviewer|merger|reconciler. Cross-role routing is the
# controller default (#840). The canonical generic queue *task type* is
# process_work_queue (controller-only below).
"allocate_next_work": {
"permission": "gitea.read",
"role": "author",
@@ -297,6 +330,19 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.read",
"role": "author",
},
# #840: documented generic queue task — controller routes only.
"process_work_queue": {
"permission": "gitea.read",
"role": "controller",
},
"process-work-queue": {
"permission": "gitea.read",
"role": "controller",
},
"cross_role_allocate": {
"permission": "gitea.read",
"role": "controller",
},
# #601 first-class lease lifecycle — inspect/list need read; mutations gate on
# ownership in the control-plane DB (not a separate Gitea write permission).
@@ -432,13 +478,84 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
},
}
# A reviewer lease is the first mutation in the canonical ``review_pr``
# workflow, so the already-resolved review capability is valid for that one
# narrower transition. Keep this directed and explicit: lease acquisition
# does not authorize a review verdict, and reviewer proof never authorizes a
# merger lease (#763).
_PREFLIGHT_TASK_TRANSITIONS = frozenset({
("review_pr", "acquire_reviewer_pr_lease"),
})
def _canonical_preflight_task(task: str | None) -> str:
"""Normalize only declared ``gitea_`` aliases for preflight comparison."""
value = (task or "").strip()
if value.startswith("gitea_") and value[6:] in TASK_CAPABILITY_MAP:
return value[6:]
return value
def preflight_task_matches(
resolved_task: str | None,
mutation_task: str | None,
) -> bool:
"""Return whether capability proof authorizes this mutation transition."""
resolved = _canonical_preflight_task(resolved_task)
mutation = _canonical_preflight_task(mutation_task)
if not resolved or not mutation:
return False
return resolved == mutation or (resolved, mutation) in _PREFLIGHT_TASK_TRANSITIONS
# Tasks for which permission alone is insufficient: the active/configured
# profile's declared role must also match the task role. This is the complete
# resolver set from master at the #723 reconstruction point, shared with
# runtime reporting so those two authorities cannot drift again.
ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
{
"acquire_reviewer_pr_lease",
"gitea_acquire_reviewer_pr_lease",
"review_pr",
"approve_pr",
"request_changes_pr",
"blind_pr_queue_review",
"pr_queue_cleanup",
"pr-queue-cleanup",
"merge_pr",
"acquire_merger_pr_lease",
"gitea_acquire_merger_pr_lease",
"adopt_merger_pr_lease",
"gitea_adopt_merger_pr_lease",
"release_merger_pr_lease",
"gitea_release_merger_pr_lease",
"create_branch",
"push_branch",
"publish_unpublished_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."
)
),
}
+29
View File
@@ -167,6 +167,35 @@ def _reset_mutation_authority(monkeypatch):
import pytest
@pytest.fixture(autouse=True)
def _hermetic_live_remote_master_head():
"""#610 / PR #788 F1/F2: keep live-remote parity reads offline in tests.
``read_remote_master_head`` would otherwise ``git ls-remote`` whenever
``GITEA_TEST_LIVE_REMOTE_HEAD`` is unset. Feature worktrees under
``branches/`` always differ from live master, so legacy suites that assert
runtime-context ``safe_next_action`` flip to live_stale. Module-level
hermetic mode survives ``patch.dict(os.environ, , clear=True)``.
Tests that exercise the real probe path call
``master_parity_gate.set_hermetic_test_mode(False)`` and/or set
``GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE``.
"""
try:
import master_parity_gate as _mpg
_mpg.set_hermetic_test_mode(True)
except Exception:
_mpg = None
try:
yield
finally:
if _mpg is not None:
try:
_mpg.set_hermetic_test_mode(False)
except Exception:
pass
@pytest.fixture(autouse=True)
def _deterministic_workspace_remotes():
try:
+266
View File
@@ -0,0 +1,266 @@
"""Dependency parsing/resolution and allocator completeness tests (#758).
Covers the two defects behind #758:
* Defect 1 candidate truncation before ranking, which let a result-size
parameter change the winner.
* Defect 2 dependency state inferred from body substrings, which emitted
canonical ``Depends:`` blocked issues as eligible.
No production behavior is special-cased for any issue number (#758 AC14), so
these tests use synthetic issue numbers throughout.
"""
from __future__ import annotations
import os
import tempfile
import unittest
import allocator_dependencies
from allocator_service import (
OUTCOME_PREVIEW,
SELECTION_POLICY,
WorkCandidate,
allocate_next_work,
classify_skip,
sort_candidates,
)
from control_plane_db import ControlPlaneDB
# The canonical linkage line this repository writes into issue bodies.
CANONICAL_BODY = (
"## Dependencies and linkage\n\n"
"* Parent: #900 · Depends: #901, #902 · Related: #903, #904\n"
)
class ParseDependencyRefsTest(unittest.TestCase):
def test_parses_canonical_depends_field(self) -> None:
self.assertEqual(
allocator_dependencies.parse_dependency_refs(CANONICAL_BODY),
(901, 902),
)
def test_stops_at_sibling_field_and_ignores_related(self) -> None:
""""Related:" refs must never be treated as dependencies."""
refs = allocator_dependencies.parse_dependency_refs(CANONICAL_BODY)
self.assertNotIn(903, refs)
self.assertNotIn(904, refs)
self.assertNotIn(900, refs) # Parent is not a dependency
def test_single_reference(self) -> None:
body = "* Parent: #10 · Depends: #11 · Related: #12"
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (11,))
def test_depends_on_spelling_and_and_separator(self) -> None:
body = "Depends on #21 and #22\n"
self.assertEqual(
allocator_dependencies.parse_dependency_refs(body), (21, 22)
)
def test_newline_terminates_declaration(self) -> None:
body = "Depends: #31, #32\nRelated: #33\n"
self.assertEqual(
allocator_dependencies.parse_dependency_refs(body), (31, 32)
)
def test_legacy_blocked_on_marker_still_recognized(self) -> None:
body = "This work is blocked on #41 until that lands.\n"
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (41,))
def test_dependencies_heading_alone_is_not_a_declaration(self) -> None:
""""Dependencies and linkage" must not parse as "Depends"."""
body = "## Dependencies and linkage\n\n* Related: #51\n"
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
def test_deduplicates_and_preserves_order(self) -> None:
body = "Depends: #61, #62, #61\n"
self.assertEqual(
allocator_dependencies.parse_dependency_refs(body), (61, 62)
)
def test_malformed_and_empty_inputs(self) -> None:
for body in ("", None, "Depends:", "Depends: none", "Depends: TBD\n"):
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
class ResolveDependencyStateTest(unittest.TestCase):
def test_open_dependency_is_unmet(self) -> None:
result = allocator_dependencies.resolve_dependency_state(
(901, 902), lambda n: "open", subject="issue#644"
)
self.assertTrue(result["dependency_unmet"])
self.assertEqual(result["unmet"], (901, 902))
self.assertIn("#901", result["reason"])
def test_closed_dependencies_are_met(self) -> None:
result = allocator_dependencies.resolve_dependency_state(
(901, 902), lambda n: "closed"
)
self.assertFalse(result["dependency_unmet"])
self.assertEqual(result["met"], (901, 902))
self.assertIsNone(result["reason"])
def test_mixed_open_and_closed_is_unmet(self) -> None:
states = {901: "closed", 902: "open"}
result = allocator_dependencies.resolve_dependency_state(
(901, 902), states.get
)
self.assertTrue(result["dependency_unmet"])
self.assertEqual(result["unmet"], (902,))
self.assertEqual(result["met"], (901,))
def test_unavailable_evidence_fails_closed(self) -> None:
"""AC7: unknown state must block, never pass."""
result = allocator_dependencies.resolve_dependency_state(
(901,), lambda n: None
)
self.assertTrue(result["dependency_unmet"])
self.assertEqual(result["unavailable"], (901,))
self.assertIn("fail closed", result["reason"])
def test_raising_lookup_fails_closed(self) -> None:
def boom(_n: int) -> str:
raise RuntimeError("lookup exploded")
result = allocator_dependencies.resolve_dependency_state((901,), boom)
self.assertTrue(result["dependency_unmet"])
self.assertEqual(result["unavailable"], (901,))
def test_no_refs_is_eligible(self) -> None:
result = allocator_dependencies.resolve_dependency_state((), lambda n: None)
self.assertFalse(result["dependency_unmet"])
self.assertIsNone(result["reason"])
class DependencyBlockedCandidateTest(unittest.TestCase):
"""A dependency-blocked candidate must be skipped, not selected."""
def test_classify_skip_rejects_unmet_dependency(self) -> None:
candidate = WorkCandidate(
kind="issue",
number=644,
labels=("status:ready",),
priority=20,
dependency_unmet=True,
dependency_reason="issue#644 depends on unresolved issue(s) #633",
)
reason = classify_skip(candidate, role="author", terminal_pr=None)
self.assertIsNotNone(reason)
self.assertIn("#633", reason)
class SelectionInvarianceTest(unittest.TestCase):
"""AC1/AC2/AC11: ranking sees everything; result bounds cannot move the winner."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
@staticmethod
def _ready_issue(number: int, **kw) -> WorkCandidate:
return WorkCandidate(
kind="issue",
number=number,
labels=("status:ready",),
priority=20,
title=f"issue {number}",
**kw,
)
def _preview(self, candidates):
return allocate_next_work(
self.db,
session_id="s-758",
role="author",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
candidates=candidates,
apply=False,
)
def test_more_than_fifty_candidates_lowest_number_wins(self) -> None:
"""Winner is the oldest eligible issue across a >50 inventory."""
candidates = [self._ready_issue(n) for n in range(600, 700)] # 100 items
result = self._preview(candidates)
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
self.assertEqual(result["selected"]["number"], 600)
def test_selection_is_invariant_to_candidate_ordering(self) -> None:
"""Ranking must not depend on the order the inventory arrived in."""
forward = [self._ready_issue(n) for n in range(600, 700)]
reverse = list(reversed(forward))
self.assertEqual(
self._preview(forward)["selected"]["number"],
self._preview(reverse)["selected"]["number"],
)
def test_truncating_inventory_changes_winner(self) -> None:
"""Regression guard: this is exactly what pre-ranking slicing did.
A 50-item slice of a 100-item inventory yields a different winner, so
any future reintroduction of pre-ranking truncation is detectable.
"""
full = [self._ready_issue(n) for n in range(600, 700)]
sliced = sorted(full, key=lambda c: -c.number)[:50]
self.assertNotEqual(
self._preview(full)["selected"]["number"],
self._preview(sliced)["selected"]["number"],
)
def test_blocked_first_candidate_falls_through_to_next(self) -> None:
"""AC8: a blocked winner must not end the iteration."""
blocked = self._ready_issue(
600,
dependency_unmet=True,
dependency_reason="issue#600 depends on unresolved issue(s) #599",
)
result = self._preview([blocked, self._ready_issue(601)])
self.assertEqual(result["selected"]["number"], 601)
skipped = {s["number"] for s in result["skipped"]}
self.assertIn(600, skipped)
def test_all_blocked_yields_no_safe_work(self) -> None:
candidates = [
self._ready_issue(
n, dependency_unmet=True, dependency_reason=f"issue#{n} blocked"
)
for n in range(600, 605)
]
result = self._preview(candidates)
self.assertIsNone(result["selected"])
self.assertEqual(len(result["skipped"]), 5)
def test_dry_run_and_apply_select_identically(self) -> None:
"""AC9: apply mode must not re-rank differently from preview."""
candidates = [self._ready_issue(n) for n in range(600, 700)]
preview = self._preview(candidates)
applied = allocate_next_work(
self.db,
session_id="s-758-apply",
role="author",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
candidates=candidates,
apply=True,
)
self.assertEqual(
preview["selected"]["number"], applied["selected"]["number"]
)
def test_sort_is_stable_and_documented(self) -> None:
ordered = sort_candidates(
[self._ready_issue(603), self._ready_issue(601), self._ready_issue(602)]
)
self.assertEqual([c.number for c in ordered], [601, 602, 603])
self.assertIn("never affect selection", SELECTION_POLICY)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,380 @@
"""Allocator ownership exclusion tests (#765).
One session's active lease must never blockade the author queue for a
different controller. Covers: foreign lease skipped, next unclaimed candidate
selected, own task resumable, task-local blocker quarantined, all-claimed ->
wait, same profile + different controller_instance_id -> different ownership,
and claimed candidates reported in skipped results.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from allocator_service import (
OUTCOME_OWNERSHIP_DEFECT,
OUTCOME_PREVIEW,
OUTCOME_WAIT,
OWNERSHIP_FOREIGN,
OWNERSHIP_OWN,
OWNERSHIP_UNKNOWN,
SKIP_CLAIMED_BY_OTHER_SESSION,
WorkCandidate,
allocate_next_work,
classify_claim_ownership,
resolve_controller_instance_id,
)
from control_plane_db import ControlPlaneDB
REMOTE = "prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
MINE = "ctl-mine-0001"
THEIRS = "ctl-theirs-0002"
def _issue(number: int, **kwargs) -> WorkCandidate:
base = dict(
kind="issue",
number=number,
state="open",
labels=("status:ready", "type:bug"),
title=f"issue {number}",
priority=20,
)
base.update(kwargs)
return WorkCandidate(**base)
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
return {
"lease_id": f"lease-{number}",
"session_id": session_id,
"controller_instance_id": instance,
"role": "author",
"profile": "prgs-author",
"expires_at": "2026-07-20T07:06:09Z",
"work_kind": kind,
"work_number": number,
}
class AllocatorOwnershipTestCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def _allocate(
self,
candidates,
*,
claims,
session_id="sess-mine",
instance=MINE,
apply=False,
role="author",
):
return allocate_next_work(
self.db,
session_id=session_id,
role=role,
remote=REMOTE,
org=ORG,
repo=REPO,
candidates=candidates,
apply=apply,
profile_name="prgs-author",
controller_instance_id=instance,
claims=claims,
)
class TestOwnershipClassification(AllocatorOwnershipTestCase):
def test_no_claim_returns_none(self):
self.assertIsNone(
classify_claim_ownership(
None, session_id="s", controller_instance_id=MINE
)
)
def test_same_controller_instance_is_own(self):
claim = _claim(1, session_id="other-session", instance=MINE)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=MINE
),
OWNERSHIP_OWN,
)
def test_same_profile_different_instance_is_foreign(self):
"""Shared profile must not imply shared ownership."""
claim = _claim(1, session_id="other-session", instance=THEIRS)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=MINE
),
OWNERSHIP_FOREIGN,
)
def test_exact_session_match_is_own(self):
claim = _claim(1, session_id="sess-mine", instance=None)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=None
),
OWNERSHIP_OWN,
)
def test_legacy_claim_with_neither_side_identified_is_foreign(self):
"""No identities anywhere: a different session id is simply not ours."""
claim = _claim(1, session_id="someone-else", instance=None)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=None
),
OWNERSHIP_FOREIGN,
)
def test_claim_identified_but_local_undeclared_is_unknown(self):
"""Only one side identified: not comparable, so never adopt."""
claim = _claim(1, session_id="someone-else", instance=THEIRS)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=None
),
OWNERSHIP_UNKNOWN,
)
def test_local_identified_but_claim_undeclared_is_unknown(self):
"""A legacy lease may be our own under an old session id; do not guess."""
claim = _claim(1, session_id="someone-else", instance=None)
self.assertEqual(
classify_claim_ownership(
claim, session_id="sess-mine", controller_instance_id=MINE
),
OWNERSHIP_UNKNOWN,
)
def test_resolve_controller_instance_id_reads_env(self):
self.assertEqual(
resolve_controller_instance_id({"GITEA_CONTROLLER_INSTANCE_ID": MINE}),
MINE,
)
self.assertIsNone(resolve_controller_instance_id({}))
self.assertIsNone(
resolve_controller_instance_id({"GITEA_CONTROLLER_INSTANCE_ID": " "})
)
class TestForeignLeaseDoesNotBlockade(AllocatorOwnershipTestCase):
def test_foreign_claim_skipped_and_next_issue_selected(self):
"""Skip the claimed issue, select the next unclaimed one."""
candidates = [_issue(607), _issue(615), _issue(617)]
claims = {
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
}
result = self._allocate(candidates, claims=claims)
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
self.assertEqual(result["selected"]["number"], 615)
skipped_607 = [s for s in result["skipped"] if s["number"] == 607]
self.assertEqual(len(skipped_607), 1)
self.assertEqual(
skipped_607[0]["reason_code"], SKIP_CLAIMED_BY_OTHER_SESSION
)
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, skipped_607[0]["reason"])
def test_claimed_candidate_appears_in_skipped_inventory(self):
"""Skipped reporting must reflect claimed candidates."""
candidates = [_issue(607), _issue(615)]
claims = {
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
}
result = self._allocate(candidates, claims=claims)
self.assertEqual(len(result["skipped"]), 1)
self.assertEqual(len(result["claims_excluded"]), 1)
excluded = result["claims_excluded"][0]
self.assertEqual(excluded["number"], 607)
self.assertEqual(excluded["ownership"], OWNERSHIP_FOREIGN)
self.assertEqual(excluded["owner_controller_instance_id"], THEIRS)
def test_task_local_blocker_does_not_freeze_unrelated_work(self):
"""A quarantined task must not stop the rest of the queue."""
candidates = [_issue(607), _issue(615), _issue(617)]
claims = {
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
}
first = self._allocate(candidates, claims=claims)
self.assertEqual(first["selected"]["number"], 615)
# 615 then gets claimed by yet another controller; queue still advances.
claims[("issue", 615)] = _claim(
615, session_id="sess-third", instance="ctl-third-0003"
)
second = self._allocate(candidates, claims=claims)
self.assertEqual(second["selected"]["number"], 617)
def test_multiple_controllers_get_different_issues(self):
"""Concurrent author sessions work on different issues."""
candidates = [_issue(607), _issue(615)]
claims = {
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
}
mine = self._allocate(candidates, claims=claims, instance=MINE)
theirs = self._allocate(
candidates, claims=claims, session_id="sess-theirs", instance=THEIRS
)
self.assertEqual(mine["selected"]["number"], 615)
# The other controller may still be handed its own in-progress task.
self.assertEqual(theirs["selected"]["number"], 607)
def test_unclaimed_queue_is_unaffected(self):
candidates = [_issue(607), _issue(615)]
result = self._allocate(candidates, claims={})
self.assertEqual(result["selected"]["number"], 607)
self.assertEqual(result["skipped"], [])
self.assertEqual(result["claims_excluded"], [])
class TestOwnTaskResume(AllocatorOwnershipTestCase):
def test_controller_may_resume_its_own_active_task(self):
"""Own claim stays selectable across a new session id."""
candidates = [_issue(607), _issue(615)]
claims = {
("issue", 607): _claim(607, session_id="sess-mine-old", instance=MINE)
}
result = self._allocate(
candidates, claims=claims, session_id="sess-mine-new", instance=MINE
)
self.assertEqual(result["selected"]["number"], 607)
self.assertEqual(result["claims_excluded"], [])
def test_own_claim_by_exact_session_is_selectable(self):
candidates = [_issue(607)]
claims = {("issue", 607): _claim(607, session_id="sess-mine", instance=None)}
result = self._allocate(
candidates, claims=claims, session_id="sess-mine", instance=None
)
self.assertEqual(result["selected"]["number"], 607)
class TestAllCandidatesClaimed(AllocatorOwnershipTestCase):
def test_all_claimed_returns_wait_not_a_claimed_selection(self):
"""Never hand back a claimed issue; report waiting instead."""
candidates = [_issue(607), _issue(615)]
claims = {
("issue", 607): _claim(607, session_id="sess-a", instance=THEIRS),
("issue", 615): _claim(615, session_id="sess-b", instance="ctl-c-0003"),
}
result = self._allocate(candidates, claims=claims)
self.assertIsNone(result["selected"])
self.assertEqual(result["outcome"], OUTCOME_WAIT)
self.assertEqual(len(result["claims_excluded"]), 2)
def test_unidentifiable_owner_reports_ownership_defect(self):
"""Refuse to adopt when ownership cannot be established."""
candidates = [_issue(607)]
claims = {("issue", 607): _claim(607, session_id="sess-legacy", instance=None)}
result = self._allocate(candidates, claims=claims)
self.assertIsNone(result["selected"])
self.assertEqual(result["outcome"], OUTCOME_OWNERSHIP_DEFECT)
self.assertEqual(len(result["ownership_defects"]), 1)
self.assertEqual(
result["ownership_defects"][0]["ownership"], OWNERSHIP_UNKNOWN
)
class TestClaimsFromControlPlaneDb(AllocatorOwnershipTestCase):
"""End-to-end against the real substrate, not injected claim dicts."""
def _seed_lease(self, number: int, *, session_id: str, instance: str | None):
self.db.upsert_session(
session_id=session_id,
role="author",
profile="prgs-author",
pid=4242,
controller_instance_id=instance,
)
return self.db.assign_and_lease(
session_id=session_id,
role="author",
remote=REMOTE,
org=ORG,
repo=REPO,
kind="issue",
number=number,
)
def test_controller_instance_id_persists_on_session(self):
row = self.db.upsert_session(
session_id="sess-x",
role="author",
profile="prgs-author",
pid=1,
controller_instance_id=MINE,
)
self.assertEqual(row["controller_instance_id"], MINE)
def test_heartbeat_without_instance_does_not_erase_ownership(self):
self.db.upsert_session(
session_id="sess-x",
role="author",
profile="prgs-author",
pid=1,
controller_instance_id=MINE,
)
row = self.db.upsert_session(
session_id="sess-x", role="author", profile="prgs-author", pid=1
)
self.assertEqual(row["controller_instance_id"], MINE)
def test_list_active_claims_surfaces_owner_instance(self):
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
claims = self.db.list_active_claims(remote=REMOTE, org=ORG, repo=REPO)
self.assertIn(("issue", 607), claims)
self.assertEqual(claims[("issue", 607)]["controller_instance_id"], THEIRS)
def test_live_foreign_lease_is_excluded_without_injected_claims(self):
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
result = allocate_next_work(
self.db,
session_id="sess-mine",
role="author",
remote=REMOTE,
org=ORG,
repo=REPO,
candidates=[_issue(607), _issue(615)],
apply=False,
profile_name="prgs-author",
controller_instance_id=MINE,
)
self.assertEqual(result["selected"]["number"], 615)
self.assertEqual(
result["skipped"][0]["reason_code"], SKIP_CLAIMED_BY_OTHER_SESSION
)
def test_apply_reserves_the_unclaimed_issue(self):
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
result = allocate_next_work(
self.db,
session_id="sess-mine",
role="author",
remote=REMOTE,
org=ORG,
repo=REPO,
candidates=[_issue(607), _issue(615)],
apply=True,
profile_name="prgs-author",
controller_instance_id=MINE,
)
self.assertEqual(result["outcome"], "assigned_work")
self.assertEqual(result["selected"]["number"], 615)
self.assertEqual(result["assignment"]["work_number"], 615)
if __name__ == "__main__":
unittest.main()
+227
View File
@@ -0,0 +1,227 @@
"""MCP-level allocator inventory and dependency regressions (#758).
Exercises ``_allocator_candidates_from_gitea`` and the ``gitea_allocate_next_work``
tool end to end against a faked Gitea API, proving:
* the complete open-issue inventory is ranked (no pre-ranking truncation);
* ``limit`` cannot change which candidate wins;
* canonical ``Depends:`` declarations are resolved from live issue state;
* unavailable dependency evidence fails closed;
* an incomplete listing fails closed instead of ranking a partial set.
Issue numbers here are synthetic; no production number is special-cased.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import patch
import gitea_mcp_server as srv
from control_plane_db import ControlPlaneDB
FAKE_AUTH = "token REDACTED"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
def _issue(number: int, *, body: str = "", labels=("status:ready",)) -> dict:
return {
"number": number,
"title": f"issue {number}",
"body": body,
"labels": [{"name": name} for name in labels],
"state": "open",
}
def _depends_body(*refs: int) -> str:
joined = ", ".join(f"#{r}" for r in refs)
return f"## Dependencies and linkage\n\n* Parent: #999 · Depends: {joined}\n"
class _FakeGitea:
"""Minimal stand-in for the two Gitea list endpoints plus issue lookups."""
def __init__(self, issues, *, closed=(), unavailable=(), fail_issue_list=False):
self.issues = list(issues)
self.closed = set(closed)
self.unavailable = set(unavailable)
self.fail_issue_list = fail_issue_list
self.lookups: list[int] = []
def api_get_all(self, url, _auth, **_kw):
if "/pulls" in url:
return []
if self.fail_issue_list:
raise RuntimeError("issue listing failed")
return list(self.issues)
def api_request(self, _method, url, _auth, **_kw):
number = int(url.rsplit("/", 1)[-1])
self.lookups.append(number)
if number in self.unavailable:
raise RuntimeError("lookup failed")
if number in self.closed:
return {"number": number, "state": "closed"}
return {"number": number, "state": "open"}
class AllocatorInventoryTest(unittest.TestCase):
"""Direct tests of the candidate loader."""
def _load(self, fake, **kwargs):
with patch("gitea_mcp_server._resolve", return_value=("h", ORG, REPO)), patch(
"gitea_mcp_server._auth", return_value=FAKE_AUTH
), patch("gitea_mcp_server.api_get_all", side_effect=fake.api_get_all), patch(
"gitea_mcp_server.api_request", side_effect=fake.api_request
):
return srv._allocator_candidates_from_gitea(
remote="prgs", host=None, org=ORG, repo=REPO, **kwargs
)
def test_full_inventory_above_fifty_is_ranked(self) -> None:
"""AC1: all 73 open issues become candidates, not the first 50."""
fake = _FakeGitea([_issue(n) for n in range(600, 673)])
candidates, _reasons, complete = self._load(fake)
self.assertTrue(complete)
self.assertEqual(len(candidates), 73)
self.assertEqual(min(c.number for c in candidates), 600)
self.assertEqual(max(c.number for c in candidates), 672)
def test_open_dependency_marks_candidate_unmet(self) -> None:
"""AC4/AC5/AC6: canonical Depends on an open issue blocks the candidate."""
fake = _FakeGitea(
[_issue(600, body=_depends_body(601, 602)), _issue(601), _issue(602)]
)
candidates, _reasons, _complete = self._load(fake)
blocked = next(c for c in candidates if c.number == 600)
self.assertTrue(blocked.dependency_unmet)
self.assertIn("#601", blocked.dependency_reason)
def test_closed_dependency_is_eligible(self) -> None:
"""A dependency absent from the open list is confirmed closed, not assumed."""
fake = _FakeGitea([_issue(600, body=_depends_body(500))], closed={500})
candidates, _reasons, _complete = self._load(fake)
candidate = next(c for c in candidates if c.number == 600)
self.assertFalse(candidate.dependency_unmet)
self.assertIn(500, fake.lookups) # proved live, not inferred
def test_unavailable_dependency_evidence_fails_closed(self) -> None:
"""AC7: an unreachable dependency must block, never pass."""
fake = _FakeGitea([_issue(600, body=_depends_body(500))], unavailable={500})
candidates, _reasons, _complete = self._load(fake)
candidate = next(c for c in candidates if c.number == 600)
self.assertTrue(candidate.dependency_unmet)
self.assertIn("fail closed", candidate.dependency_reason)
def test_dependency_state_lookups_are_cached(self) -> None:
"""Repeated references resolve with a single live lookup."""
fake = _FakeGitea(
[_issue(n, body=_depends_body(500)) for n in range(600, 610)],
closed={500},
)
self._load(fake)
self.assertEqual(fake.lookups.count(500), 1)
def test_open_dependency_needs_no_lookup(self) -> None:
"""The complete open listing already proves openness."""
fake = _FakeGitea([_issue(600, body=_depends_body(601)), _issue(601)])
self._load(fake)
self.assertNotIn(601, fake.lookups)
def test_failed_issue_listing_reports_incomplete(self) -> None:
"""AC3: a failed listing must not silently yield a short inventory."""
fake = _FakeGitea([], fail_issue_list=True)
_candidates, reasons, complete = self._load(fake)
self.assertFalse(complete)
self.assertTrue(any("failed to list open issues" in r for r in reasons))
class AllocateNextWorkToolTest(unittest.TestCase):
"""End-to-end tests of the gitea_allocate_next_work MCP tool."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _allocate(self, fake, **kwargs):
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
), patch("gitea_mcp_server._auth", return_value=FAKE_AUTH), patch(
"gitea_mcp_server.get_profile",
return_value={"profile_name": "prgs-author", "role": "author"},
), patch(
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
), patch(
"gitea_mcp_server.api_get_all", side_effect=fake.api_get_all
), patch(
"gitea_mcp_server.api_request", side_effect=fake.api_request
), patch(
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
):
return srv.gitea_allocate_next_work(
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
)
def test_limit_does_not_change_selection(self) -> None:
"""AC2/AC11: the winner is identical at limit=1 and limit=300."""
issues = [_issue(n) for n in range(600, 673)] # 73 candidates
low = self._allocate(_FakeGitea(issues), limit=1)
high = self._allocate(_FakeGitea(issues), limit=300)
self.assertEqual(low["selected"]["number"], high["selected"]["number"])
self.assertEqual(low["selected"]["number"], 600)
self.assertEqual(low["candidate_count"], 73)
self.assertEqual(high["candidate_count"], 73)
def test_dependency_blocked_winner_falls_through(self) -> None:
"""AC8: a blocked highest-ranked issue yields the next eligible one."""
issues = [
_issue(600, body=_depends_body(601)),
_issue(601),
_issue(602),
]
result = self._allocate(_FakeGitea(issues))
# 600 is blocked by open 601; 601 itself is a valid candidate.
self.assertEqual(result["selected"]["number"], 601)
skipped = {s["number"] for s in result["skipped"]}
self.assertIn(600, skipped)
def test_limit_truncates_only_the_reported_skip_list(self) -> None:
"""A shortened report is labelled, never presented as full coverage."""
issues = [_issue(n, body=_depends_body(999)) for n in range(600, 640)]
issues.append(_issue(999)) # open dependency blocks all of the above
issues.append(_issue(700)) # the one eligible candidate
result = self._allocate(_FakeGitea(issues), limit=5)
self.assertTrue(result["skipped_report_truncated"])
self.assertEqual(len(result["skipped"]), 5)
self.assertGreater(result["skipped_total"], 5)
self.assertEqual(result["limit_applies_to"], "reported_skip_list_only")
def test_incomplete_inventory_fails_closed(self) -> None:
"""AC3: no selection is made from a partial candidate set."""
result = self._allocate(_FakeGitea([], fail_issue_list=True))
self.assertFalse(result["success"])
self.assertFalse(result["inventory_complete"])
self.assertIsNone(result["assignment"])
self.assertTrue(
any("fail closed" in r for r in result["reasons"]),
result["reasons"],
)
def test_selection_policy_is_reported(self) -> None:
"""AC10: tie-breaking is stated in the result, not left implicit."""
result = self._allocate(_FakeGitea([_issue(600)]))
self.assertIn("selection_policy", result)
self.assertIn("number asc", result["selection_policy"])
if __name__ == "__main__":
unittest.main()
+349
View File
@@ -0,0 +1,349 @@
"""Allocator pre-rank exclusions and candidates_json transport (#776).
Covers:
* #617 excluded before ranking (never leased when exclude_issue_numbers=[617]);
* excluded top candidate selects the next safe candidate;
* all candidates excluded WAIT, no lease;
* decoded-list and JSON-string candidates_json;
* malformed / type-invalid fail-closed cases;
* dry-run/apply fingerprint match and drift rejection;
* foreign lease and same-owner lease on excluded issue;
* skipped-accounting reason parity (excluded_by_controller);
* public MCP entry-point coverage for exclude_issue_numbers.
"""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from unittest.mock import patch
import gitea_mcp_server as srv
from allocator_service import (
OUTCOME_ASSIGNED,
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
OUTCOME_CANDIDATE_SET_DRIFT,
OUTCOME_NO_SAFE,
OUTCOME_PREVIEW,
OUTCOME_WAIT,
SKIP_CLAIMED_BY_OTHER_SESSION,
SKIP_EXCLUDED_BY_CONTROLLER,
WorkCandidate,
allocate_next_work,
candidate_from_dict,
candidate_set_fingerprint,
normalize_candidates_payload,
normalize_exclude_issue_numbers,
)
from control_plane_db import ControlPlaneDB
REMOTE = "prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
MINE = "ctl-mine-776"
THEIRS = "ctl-theirs-776"
def _issue(number: int, **kwargs) -> WorkCandidate:
base = dict(
kind="issue",
number=number,
state="open",
labels=("status:ready", "type:bug"),
title=f"issue {number}",
priority=20,
)
base.update(kwargs)
return WorkCandidate(**base)
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
return {
"lease_id": f"lease-{number}",
"session_id": session_id,
"controller_instance_id": instance,
"role": "author",
"profile": "prgs-author",
"expires_at": "2026-07-21T12:00:00Z",
"work_kind": kind,
"work_number": number,
}
def _cand_dict(number: int, **kwargs) -> dict:
d = {
"kind": "issue",
"number": number,
"state": "open",
"labels": ["status:ready", "type:bug"],
"title": f"issue {number}",
"priority": 20,
}
d.update(kwargs)
return d
class AllocatorExcludeServiceTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def _alloc(self, candidates, **kwargs):
defaults = dict(
session_id="sess-776",
role="author",
remote=REMOTE,
org=ORG,
repo=REPO,
profile_name="prgs-author",
controller_instance_id=MINE,
claims={},
apply=False,
)
defaults.update(kwargs)
return allocate_next_work(self.db, candidates=candidates, **defaults)
def test_exclude_617_before_ranking_never_selects(self) -> None:
"""AC2/AC8: highest-ranked #617 is removed before ranking."""
cands = [_issue(617), _issue(700), _issue(701)]
res = self._alloc(cands, exclude_issue_numbers=[617])
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
self.assertEqual(res["selected"]["number"], 700)
skipped = {s["number"]: s for s in res["skipped"]}
self.assertIn(617, skipped)
self.assertEqual(
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
)
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, skipped[617]["reason"])
def test_excluded_top_selects_next_safe(self) -> None:
"""AC2: excluding the oldest ready issue promotes the next number."""
cands = [_issue(600), _issue(601), _issue(602)]
res = self._alloc(cands, exclude_issue_numbers=[600])
self.assertEqual(res["selected"]["number"], 601)
def test_all_candidates_excluded_wait_no_lease(self) -> None:
"""AC7: every candidate excluded → WAIT, no assignment."""
cands = [_issue(617), _issue(700)]
res = self._alloc(cands, exclude_issue_numbers=[617, 700], apply=True)
self.assertEqual(res["outcome"], OUTCOME_WAIT)
self.assertIsNone(res["selected"])
self.assertIsNone(res["assignment"])
self.assertEqual(len(res["controller_excluded"]), 2)
def test_omit_exclude_retains_existing_behavior(self) -> None:
"""AC1/AC9: omit exclusions → #617 still wins when oldest ready."""
cands = [_issue(617), _issue(700)]
res = self._alloc(cands)
self.assertEqual(res["selected"]["number"], 617)
self.assertEqual(res.get("exclude_issue_numbers"), [])
def test_foreign_lease_still_skipped(self) -> None:
"""AC6/AC9: foreign claims keep SKIP_CLAIMED_BY_OTHER_SESSION."""
cands = [_issue(617), _issue(700)]
claims = {
("issue", 700): _claim(700, session_id="other", instance=THEIRS),
}
res = self._alloc(
cands, exclude_issue_numbers=[617], claims=claims
)
# 617 excluded, 700 foreign → wait, no selection
self.assertEqual(res["outcome"], OUTCOME_WAIT)
self.assertIsNone(res["selected"])
codes = {s["reason_code"] for s in res["skipped"]}
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, codes)
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, codes)
def test_same_owner_lease_on_excluded_blocks_resume_release(self) -> None:
"""AC5: excluded + live same-owner lease → structured blocker."""
cands = [_issue(617), _issue(700)]
claims = {
("issue", 617): _claim(617, session_id="sess-776", instance=MINE),
}
res = self._alloc(
cands, exclude_issue_numbers=[617], claims=claims, apply=True
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE)
self.assertIsNone(res["assignment"])
self.assertEqual(res["blocked_lease"]["number"], 617)
self.assertIn("resume", res["blocked_lease"]["safe_next_action"])
def test_dry_run_apply_fingerprint_match(self) -> None:
"""AC4: dry-run and apply share the same fingerprint."""
cands = [_issue(617), _issue(700)]
dry = self._alloc(cands, exclude_issue_numbers=[617], apply=False)
apply_res = self._alloc(
cands,
exclude_issue_numbers=[617],
apply=True,
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
)
self.assertEqual(
dry["candidate_set_fingerprint"],
apply_res["candidate_set_fingerprint"],
)
self.assertEqual(apply_res["outcome"], OUTCOME_ASSIGNED)
self.assertEqual(apply_res["selected"]["number"], 700)
def test_apply_rejects_fingerprint_drift(self) -> None:
"""AC4: material candidate-set drift fails closed on apply."""
cands = [_issue(617), _issue(700)]
res = self._alloc(
cands,
exclude_issue_numbers=[617],
apply=True,
expected_candidate_set_fingerprint="0" * 64,
)
self.assertFalse(res["success"])
self.assertEqual(res["outcome"], OUTCOME_CANDIDATE_SET_DRIFT)
self.assertIsNone(res["assignment"])
def test_fingerprint_stable_helper(self) -> None:
cands = [_issue(700), _issue(617)]
a = candidate_set_fingerprint(cands, exclude_issue_numbers=[617])
b = candidate_set_fingerprint(
list(reversed(cands)), exclude_issue_numbers=[617]
)
self.assertEqual(a, b)
def test_normalize_exclude_rejects_bool(self) -> None:
with self.assertRaises(ValueError):
normalize_exclude_issue_numbers([True])
def test_normalize_exclude_rejects_scalar(self) -> None:
with self.assertRaises(ValueError):
normalize_exclude_issue_numbers(617)
class CandidatesJsonNormalizeTest(unittest.TestCase):
def test_decoded_list(self) -> None:
"""AC3: already-decoded list from MCP transport."""
cands = normalize_candidates_payload([_cand_dict(617), _cand_dict(700)])
self.assertEqual([c.number for c in cands], [617, 700])
def test_json_string(self) -> None:
"""AC3: backward-compatible JSON string."""
raw = json.dumps([_cand_dict(617)])
cands = normalize_candidates_payload(raw)
self.assertEqual(cands[0].number, 617)
def test_malformed_json_fail_closed(self) -> None:
with self.assertRaises(ValueError) as ctx:
normalize_candidates_payload("{not json")
self.assertIn("malformed", str(ctx.exception).lower())
def test_scalar_fail_closed(self) -> None:
with self.assertRaises(ValueError):
normalize_candidates_payload(42)
def test_bool_number_fail_closed(self) -> None:
with self.assertRaises(ValueError):
normalize_candidates_payload([_cand_dict(True)]) # type: ignore[arg-type]
def test_invalid_record_fail_closed(self) -> None:
with self.assertRaises(ValueError):
normalize_candidates_payload(["not-a-dict"])
def test_object_not_list_fail_closed(self) -> None:
with self.assertRaises(ValueError):
normalize_candidates_payload(json.dumps({"number": 1}))
def test_candidate_from_dict_rejects_bool_number(self) -> None:
with self.assertRaises(ValueError):
candidate_from_dict({"kind": "issue", "number": True})
class AllocateNextWorkMcpExcludeTest(unittest.TestCase):
"""Public MCP entry-point coverage (#776 AC8)."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _call(self, **kwargs):
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
), patch(
"gitea_mcp_server.get_profile",
return_value={"profile_name": "prgs-author", "role": "author"},
), patch(
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
), patch(
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
):
return srv.gitea_allocate_next_work(
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
)
def test_mcp_exclude_617_decoded_list_never_selects(self) -> None:
"""AC8: public tool with decoded list + exclude_issue_numbers=[617]."""
candidates = [_cand_dict(617), _cand_dict(700)]
res = self._call(
candidates_json=candidates,
exclude_issue_numbers=[617],
apply=False,
)
self.assertTrue(res.get("success"), res)
self.assertEqual(res["selected"]["number"], 700)
skipped = {s["number"]: s for s in res["skipped"]}
self.assertEqual(
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
)
self.assertNotEqual(res["selected"]["number"], 617)
def test_mcp_exclude_617_json_string_apply(self) -> None:
"""AC8: JSON-string transport + apply never leases #617."""
raw = json.dumps([_cand_dict(617), _cand_dict(700)])
res = self._call(
candidates_json=raw,
exclude_issue_numbers=[617],
apply=True,
)
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
self.assertEqual(res["assignment"]["work_number"], 700)
self.assertNotEqual(res["selected"]["number"], 617)
def test_mcp_malformed_candidates_json_fail_closed(self) -> None:
res = self._call(candidates_json="{bad", apply=False)
self.assertFalse(res["success"])
self.assertIsNone(res["assignment"])
self.assertTrue(any("fail closed" in r for r in res["reasons"]))
def test_mcp_bool_number_fail_closed(self) -> None:
res = self._call(
candidates_json=[{"kind": "issue", "number": True, "priority": 20}],
apply=False,
)
self.assertFalse(res["success"])
self.assertIsNone(res["assignment"])
def test_mcp_fingerprint_dry_run_apply_parity(self) -> None:
candidates = [_cand_dict(617), _cand_dict(700)]
dry = self._call(
candidates_json=candidates,
exclude_issue_numbers=[617],
apply=False,
)
apply_res = self._call(
candidates_json=candidates,
exclude_issue_numbers=[617],
apply=True,
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
)
self.assertEqual(
dry["candidate_set_fingerprint"],
apply_res["candidate_set_fingerprint"],
)
self.assertEqual(apply_res["selected"]["number"], 700)
if __name__ == "__main__":
unittest.main()
+572
View File
@@ -0,0 +1,572 @@
"""Executable acceptance tests for ARCH-01 Slice A (#822).
Each acceptance criterion (#822 §12) and named test (#822 §13) is exercised
against a real SQLite database. The migration runs on a fresh DB in ``setUp``;
the test-run output is the durable evidence the issue requires (§14).
Enforcement being proven:
* ``[TRUSTED-SERVICE]`` the ``cp_*`` actor functions exist only on the
trusted kernel connection; a raw connection cannot satisfy the triggers.
* ``[SCHEMA]`` fail-closed aborts, exact dominance set, NOT-NULL class,
immutability, and the last-active-grant floor are enforced by
CHECK/FK/trigger, verified here including raw-write bypass and concurrency.
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
import arch01_platform as ap
from arch01_platform import (
ALREADY_INSTALLED,
AUTHORIZATION_DENIED,
CONCURRENT_INSTALLATION_LOST,
DISTINGUISHED_ISSUER_ID,
DOMINANCE_SET_MISMATCH,
DOMINANCE_TUPLES,
INSTALLED,
INVALID_ACTOR_CONTEXT,
INVALID_BOOTSTRAP_STATE,
PlatformKernel,
)
INSTALLER = "platform.installer"
_BOOTSTRAP_TABLES = (
"principal_equivalence_classes",
"principals",
"authoritative_issuers",
"authority_dominance",
"platform_bootstrap_seed",
"platform_bootstrap_grants",
"platform_active_invariant",
"install_state",
)
def _count(kernel: PlatformKernel, table: str) -> int:
return kernel._conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
def _count_where(kernel: PlatformKernel, table: str, where: str) -> int:
return kernel._conn.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}").fetchone()[0]
def _all_bootstrap_empty(kernel: PlatformKernel) -> bool:
return all(_count(kernel, t) == 0 for t in _BOOTSTRAP_TABLES)
class Arch01MemoryTest(unittest.TestCase):
"""Single-connection behavior on an in-memory database."""
def setUp(self) -> None:
self.kernel = PlatformKernel(":memory:")
def tearDown(self) -> None:
self.kernel.close()
# -- AC1 -------------------------------------------------------------- #
def test_install_clean(self) -> None: # t_install_clean(+)
res = self.kernel.install_platform(INSTALLER)
self.assertEqual(res.code, INSTALLED)
self.assertTrue(self.kernel.is_installed())
self.assertEqual(_count(self.kernel, "install_state"), 1)
self.assertEqual(self.kernel.active_grant_count(), 1)
self.assertIn(ap.EVT_PLATFORM_INSTALLED, self.kernel.audit_events())
rows = set(
self.kernel._conn.execute(
"SELECT dominant, subordinate FROM authority_dominance"
).fetchall()
)
self.assertEqual(rows, set(DOMINANCE_TUPLES))
issuer_ref = self.kernel._conn.execute(
"SELECT i.issuer_ref FROM principals p JOIN authoritative_issuers i "
"ON p.issuer_id = i.issuer_id WHERE p.principal_id = ?",
(INSTALLER,),
).fetchone()
self.assertEqual(issuer_ref[0], DISTINGUISHED_ISSUER_ID)
# -- AC2 -------------------------------------------------------------- #
def test_install_twice(self) -> None: # t_install_twice(-)
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
res2 = self.kernel.install_platform(INSTALLER)
self.assertEqual(res2.code, ALREADY_INSTALLED)
self.assertEqual(_count(self.kernel, "principals"), 1)
self.assertEqual(_count(self.kernel, "platform_bootstrap_grants"), 1)
self.assertEqual(_count(self.kernel, "install_state"), 1)
# -- AC3 / AC5 -------------------------------------------------------- #
def test_install_stage_rollback(self) -> None: # t_install_stage_rollback
for stop in range(1, 9):
with self.subTest(stages=stop):
k = PlatformKernel(":memory:")
try:
self._partial_bootstrap_then_rollback(k, stop)
self.assertTrue(
_all_bootstrap_empty(k),
f"partial rows survived rollback at stage {stop}",
)
self.assertFalse(k.is_installed())
finally:
k.close()
def test_no_partial_after_rollback(self) -> None: # t_no_partial_after_rollback
k = PlatformKernel(":memory:")
try:
code = self._seed_bootstrap_and_mark(k, dominance=DOMINANCE_TUPLES[:-1])
self.assertEqual(code, DOMINANCE_SET_MISMATCH)
self.assertTrue(_all_bootstrap_empty(k))
self.assertFalse(k.is_installed())
finally:
k.close()
# -- AC4 -------------------------------------------------------------- #
def test_dominance_missing(self) -> None: # t_dominance_missing(-)
k = PlatformKernel(":memory:")
try:
self.assertEqual(
self._seed_bootstrap_and_mark(k, dominance=DOMINANCE_TUPLES[:-1]),
DOMINANCE_SET_MISMATCH,
)
self.assertFalse(k.is_installed())
finally:
k.close()
def test_dominance_extra(self) -> None: # t_dominance_extra(-)
k = PlatformKernel(":memory:")
try:
extra = DOMINANCE_TUPLES + (("platform.bootstrap", "rogue.extra"),)
self.assertEqual(
self._seed_bootstrap_and_mark(k, dominance=extra),
DOMINANCE_SET_MISMATCH,
)
self.assertFalse(k.is_installed())
finally:
k.close()
def test_dominance_malformed(self) -> None: # t_dominance_malformed(-)
k = PlatformKernel(":memory:")
try:
malformed = DOMINANCE_TUPLES[:-1] + (("supervisor.root", "WRONG.subordinate"),)
self.assertEqual(
self._seed_bootstrap_and_mark(k, dominance=malformed),
DOMINANCE_SET_MISMATCH,
)
self.assertFalse(k.is_installed())
finally:
k.close()
# -- AC6 -------------------------------------------------------------- #
def test_principal_no_class(self) -> None: # t_principal_no_class(-)
with self.kernel.actor_context("op", "operator", "install"):
with self.assertRaises(sqlite3.IntegrityError):
self.kernel._conn.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES ('x', 'operator', NULL, NULL, NULL, '2026-01-01T00:00:00Z')"
)
# -- AC7 -------------------------------------------------------------- #
def test_noninstaller_null_issuer(self) -> None: # t_nonobstaller_null_issuer(-)
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
with self.kernel.actor_context("op", "operator", "normal"):
cur = self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
class_id = cur.lastrowid
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES ('rogue', 'operator', ?, NULL, NULL, '2026-01-01T00:00:00Z')",
(class_id,),
)
self.assertIn("INVALID_BOOTSTRAP_STATE", str(ctx.exception))
def test_installer_null_issuer_only_during_install(self) -> None:
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
with self.kernel.actor_context("i2", "installer", "install"):
cur = self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
class_id = cur.lastrowid
with self.assertRaises(sqlite3.IntegrityError):
self.kernel._conn.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES ('i2', 'installer', ?, NULL, NULL, '2026-01-01T00:00:00Z')",
(class_id,),
)
# -- AC8 -------------------------------------------------------------- #
def test_context_missing(self) -> None: # t_context_missing(-)
self.assertIsNone(self.kernel._ctx)
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
def test_context_stale(self) -> None: # t_context_stale(-)
with self.kernel.actor_context("op", "operator", "normal"):
self.kernel._ctx.expired = True
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
def test_context_epoch_shift(self) -> None: # t_context_epoch_shift(-)
with self.kernel.actor_context("op", "operator", "normal"):
self.kernel._ctx.live_epoch = self.kernel._ctx.bound_epoch + 99
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
def test_bad_actor_kind_or_mode_rejected(self) -> None:
for kind, mode in (("intruder", "normal"), ("operator", "sabotage")):
with self.subTest(kind=kind, mode=mode):
with self.kernel.actor_context("op", kind, mode):
with self.assertRaises(sqlite3.IntegrityError):
self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) "
"VALUES ('2026-01-01T00:00:00Z')"
)
# -- AC9 -------------------------------------------------------------- #
def test_bootstrap_immutable_update(self) -> None: # t_bootstrap_immutable_{update}
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
cases = [
("UPDATE install_state SET installed_at = 'x' WHERE id = 1", "IMMUTABLE_INSTALL_STATE"),
("UPDATE platform_bootstrap_seed SET created_at = 'x' WHERE seed_id = 1", "IMMUTABLE_SEED"),
("UPDATE authority_dominance SET subordinate = 'x' WHERE dominant = 'supervisor.root'", "IMMUTABLE_DOMINANCE"),
(f"UPDATE authoritative_issuers SET issuer_ref = 'x' WHERE issuer_ref = '{DISTINGUISHED_ISSUER_ID}'", "IMMUTABLE_ISSUER"),
(f"UPDATE principals SET actor_kind = 'operator' WHERE principal_id = '{INSTALLER}'", "IMMUTABLE_PRINCIPAL"),
]
for sql, tag in cases:
with self.subTest(sql=sql):
with self.kernel.actor_context("op", "operator", "normal"):
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(sql)
self.assertIn(tag, str(ctx.exception))
def test_bootstrap_immutable_delete(self) -> None: # t_bootstrap_immutable_{delete}
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
cases = [
("DELETE FROM install_state WHERE id = 1", "IMMUTABLE_INSTALL_STATE"),
("DELETE FROM platform_bootstrap_seed WHERE seed_id = 1", "IMMUTABLE_SEED"),
("DELETE FROM authority_dominance", "IMMUTABLE_DOMINANCE"),
("DELETE FROM authoritative_issuers", "IMMUTABLE_ISSUER"),
(f"DELETE FROM principals WHERE principal_id = '{INSTALLER}'", "IMMUTABLE_PRINCIPAL"),
("DELETE FROM platform_bootstrap_grants", "IMMUTABLE_GRANT"),
]
for sql, tag in cases:
with self.subTest(sql=sql):
with self.kernel.actor_context("op", "operator", "normal"):
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(sql)
self.assertIn(tag, str(ctx.exception))
def test_grant_reactivation_rejected(self) -> None:
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
self.kernel.register_principal(
"op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
)
self.assertEqual(
self.kernel.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED
)
gid = self.kernel._conn.execute(
"SELECT grant_id FROM platform_bootstrap_grants WHERE grantee_principal_id = 'op1'"
).fetchone()[0]
self.assertEqual(
self.kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code,
INSTALLED,
)
with self.kernel.actor_context("op", "operator", "normal"):
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"UPDATE platform_bootstrap_grants SET active = 1 WHERE grant_id = ?",
(gid,),
)
self.assertIn("IMMUTABLE_GRANT", str(ctx.exception))
# -- AC12 ------------------------------------------------------------- #
def test_raw_write_bypass(self) -> None: # t_raw_write_bypass(raw-bypass)
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "p.sqlite3")
k = PlatformKernel(path)
self.assertEqual(k.install_platform(INSTALLER).code, INSTALLED)
k.close()
raw = sqlite3.connect(path)
raw.execute("PRAGMA foreign_keys = ON")
try:
with self.assertRaises(sqlite3.Error):
raw.execute(
"INSERT INTO audit_records(event, created_at) "
"VALUES ('forged', '2026-01-01T00:00:00Z')"
)
raw.commit()
with self.assertRaises(sqlite3.Error):
raw.execute("UPDATE install_state SET installed_at = 'x' WHERE id = 1")
raw.commit()
with self.assertRaises(sqlite3.Error):
raw.execute("DELETE FROM platform_bootstrap_grants")
raw.commit()
finally:
raw.close()
# -- AC13 ------------------------------------------------------------- #
def test_audit_created(self) -> None: # t_audit_created(+)
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
self.kernel.register_principal(
"op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
)
self.assertEqual(
self.kernel.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED
)
gid = self.kernel._conn.execute(
"SELECT grant_id FROM platform_bootstrap_grants WHERE grantee_principal_id = 'op1'"
).fetchone()[0]
self.assertEqual(
self.kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code,
INSTALLED,
)
events = self.kernel.audit_events()
for evt in (
ap.EVT_PLATFORM_INSTALLED,
ap.EVT_GRANT_CREATED,
ap.EVT_GRANT_REVOKED,
ap.EVT_PRINCIPAL_REGISTERED,
):
self.assertIn(evt, events)
# -- AC14 ------------------------------------------------------------- #
def test_audit_immutable(self) -> None: # t_audit_immutable(raw-bypass)
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
with self.kernel.actor_context("op", "operator", "normal"):
with self.assertRaises(sqlite3.IntegrityError) as up:
self.kernel._conn.execute("UPDATE audit_records SET event = 'x' WHERE audit_id = 1")
self.assertIn("IMMUTABLE_AUDIT", str(up.exception))
with self.assertRaises(sqlite3.IntegrityError) as dl:
self.kernel._conn.execute("DELETE FROM audit_records WHERE audit_id = 1")
self.assertIn("IMMUTABLE_AUDIT", str(dl.exception))
# -- meta ------------------------------------------------------------- #
def test_schema_meta(self) -> None:
rows = dict(self.kernel._conn.execute("SELECT key, value FROM arch01_meta").fetchall())
self.assertEqual(rows["schema_version"], str(ap.SCHEMA_VERSION))
self.assertIn("disabled by default", rows["architecture"])
def test_register_principal_creates_class_first(self) -> None:
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
res = self.kernel.register_principal(
"svc1", "service", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
)
self.assertEqual(res.code, INSTALLED)
row = self.kernel._conn.execute(
"SELECT current_class_id FROM principals WHERE principal_id = 'svc1'"
).fetchone()
self.assertIsNotNone(row[0])
# -- helpers ---------------------------------------------------------- #
def _partial_bootstrap_then_rollback(self, k: PlatformKernel, stop: int) -> None:
"""Execute the first ``stop`` bootstrap statements, then ROLLBACK."""
now = "2026-01-01T00:00:00Z"
k._conn.execute("BEGIN IMMEDIATE")
class_id = None
issuer_id = None
try:
with k.actor_context(INSTALLER, "installer", "install"):
c = k._conn
if stop >= 1:
class_id = c.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)", (now,)
).lastrowid
if stop >= 2:
c.execute(
"INSERT INTO principals(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES (?, 'installer', ?, NULL, ?, ?)",
(INSTALLER, class_id, INSTALLER, now),
)
if stop >= 3:
issuer_id = c.execute(
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) VALUES ('operator-key', ?, ?)",
(DISTINGUISHED_ISSUER_ID, now),
).lastrowid
if stop >= 4:
c.execute(
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
(issuer_id, INSTALLER),
)
if stop >= 5:
c.executemany(
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
DOMINANCE_TUPLES,
)
if stop >= 6:
c.execute(
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) VALUES (1, ?, ?)",
(INSTALLER, now),
)
if stop >= 7:
c.execute(
"INSERT INTO platform_bootstrap_grants(grantee_principal_id, granted_by, active, created_at) VALUES (?, NULL, 1, ?)",
(INSTALLER, now),
)
if stop >= 8:
c.execute("INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)")
finally:
k._conn.execute("ROLLBACK")
def _seed_bootstrap_and_mark(self, k: PlatformKernel, dominance) -> str:
"""Seed a full bootstrap with a caller-supplied dominance set, then
attempt the marker insert. Returns the classified failure code (or
INSTALLED). Rolls back on failure so no partial rows remain."""
now = "2026-01-01T00:00:00Z"
k._conn.execute("BEGIN IMMEDIATE")
try:
with k.actor_context(INSTALLER, "installer", "install"):
c = k._conn
class_id = c.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)", (now,)
).lastrowid
c.execute(
"INSERT INTO principals(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES (?, 'installer', ?, NULL, ?, ?)",
(INSTALLER, class_id, INSTALLER, now),
)
issuer_id = c.execute(
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) VALUES ('operator-key', ?, ?)",
(DISTINGUISHED_ISSUER_ID, now),
).lastrowid
c.execute(
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
(issuer_id, INSTALLER),
)
c.executemany(
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
dominance,
)
c.execute(
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) VALUES (1, ?, ?)",
(INSTALLER, now),
)
c.execute(
"INSERT INTO platform_bootstrap_grants(grantee_principal_id, granted_by, active, created_at) VALUES (?, NULL, 1, ?)",
(INSTALLER, now),
)
c.execute("INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)")
c.execute(
"INSERT INTO install_state(id, marker, installed_at) VALUES (1, 'installed', ?)",
(now,),
)
k._conn.execute("COMMIT")
return INSTALLED
except sqlite3.Error as exc:
k._safe_rollback()
return PlatformKernel._classify(exc)
class Arch01ConcurrencyTest(unittest.TestCase):
"""Concurrency invariants require file-backed DBs and independent connections."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self._tmp.name, "p.sqlite3")
def tearDown(self) -> None:
self._tmp.cleanup()
# -- AC10 ------------------------------------------------------------- #
def test_concurrent_install(self) -> None: # t_concurrent_install(concurrency)
k1 = PlatformKernel(self.path, busy_timeout_ms=0)
k2 = PlatformKernel(self.path, busy_timeout_ms=0)
barrier = threading.Barrier(2)
results = {}
def _install(name, kernel):
barrier.wait()
results[name] = kernel.install_platform(INSTALLER).code
try:
with ThreadPoolExecutor(max_workers=2) as ex:
f1 = ex.submit(_install, "a", k1)
f2 = ex.submit(_install, "b", k2)
f1.result()
f2.result()
codes = sorted(results.values())
self.assertEqual(codes.count(INSTALLED), 1, f"exactly one install expected: {results}")
other = [c for c in results.values() if c != INSTALLED][0]
self.assertIn(other, (ALREADY_INSTALLED, CONCURRENT_INSTALLATION_LOST))
self.assertTrue(k1.is_installed())
self.assertEqual(_count(k1, "install_state"), 1)
self.assertEqual(_count(k1, "principals"), 1)
finally:
k1.close()
k2.close()
# -- AC11 ------------------------------------------------------------- #
def test_concurrent_last_grant_revoke(self) -> None: # t_concurrent_last_grant_revoke
setup = PlatformKernel(self.path)
self.assertEqual(setup.install_platform(INSTALLER).code, INSTALLED)
setup.register_principal("op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER)
self.assertEqual(setup.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED)
self.assertEqual(setup.active_grant_count(), 2)
gids = [
r[0]
for r in setup._conn.execute(
"SELECT grant_id FROM platform_bootstrap_grants WHERE active = 1 ORDER BY grant_id"
).fetchall()
]
setup.close()
self.assertEqual(len(gids), 2)
k1 = PlatformKernel(self.path, busy_timeout_ms=3000)
k2 = PlatformKernel(self.path, busy_timeout_ms=3000)
barrier = threading.Barrier(2)
results = {}
def _revoke(name, kernel, gid):
barrier.wait()
results[name] = kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code
try:
with ThreadPoolExecutor(max_workers=2) as ex:
f1 = ex.submit(_revoke, "a", k1, gids[0])
f2 = ex.submit(_revoke, "b", k2, gids[1])
f1.result()
f2.result()
codes = list(results.values())
self.assertEqual(codes.count(INSTALLED), 1, f"exactly one revoke should win: {results}")
self.assertEqual(codes.count(AUTHORIZATION_DENIED), 1, f"one revoke must be denied: {results}")
self.assertEqual(k1.active_grant_count(), 1)
self.assertEqual(_count_where(k1, "platform_bootstrap_grants", "active = 1"), 1)
finally:
k1.close()
k2.close()
def test_revoke_final_grant_denied(self) -> None:
k = PlatformKernel(self.path)
try:
self.assertEqual(k.install_platform(INSTALLER).code, INSTALLED)
gid = k._conn.execute(
"SELECT grant_id FROM platform_bootstrap_grants WHERE active = 1"
).fetchone()[0]
res = k.revoke_platform_bootstrap(gid, actor_principal=INSTALLER)
self.assertEqual(res.code, AUTHORIZATION_DENIED)
self.assertEqual(k.active_grant_count(), 1)
self.assertEqual(_count_where(k, "platform_bootstrap_grants", "active = 1"), 1)
finally:
k.close()
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)
+581
View File
@@ -0,0 +1,581 @@
"""Authoritative controller cross-role generic queue allocation (#840)."""
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import patch
from allocator_service import (
ALLOCATION_MODE_CROSS_ROLE,
ALLOCATION_MODE_ROLE_SCOPED,
OUTCOME_NO_SAFE,
OUTCOME_PREVIEW,
OUTCOME_WAIT,
ROLE_AUTHOR,
ROLE_CONTROLLER,
ROLE_MERGER,
ROLE_RECONCILER,
ROLE_REVIEWER,
WorkCandidate,
allocate_next_work,
build_selection_dict,
classify_skip,
required_namespace_for_role,
required_profile_for_role,
resolve_allocation_mode,
selected_action_for_candidate,
)
from control_plane_db import ControlPlaneDB
import role_session_router
from role_session_router import (
ROUTE_ALLOWED,
ROUTE_AMBIGUOUS,
ROUTE_WRONG_ROLE,
route_task_session,
)
import namespace_workspace_binding as nwb
import task_capability_map
class CrossRoleAllocationModeTest(unittest.TestCase):
def test_controller_defaults_to_cross_role(self) -> None:
self.assertEqual(
resolve_allocation_mode(ROLE_CONTROLLER),
ALLOCATION_MODE_CROSS_ROLE,
)
def test_worker_defaults_to_role_scoped(self) -> None:
for role in (ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER):
self.assertEqual(
resolve_allocation_mode(role),
ALLOCATION_MODE_ROLE_SCOPED,
)
def test_explicit_modes(self) -> None:
self.assertEqual(
resolve_allocation_mode(ROLE_CONTROLLER, "role_scoped"),
ALLOCATION_MODE_ROLE_SCOPED,
)
self.assertEqual(
resolve_allocation_mode(ROLE_AUTHOR, "cross_role"),
ALLOCATION_MODE_CROSS_ROLE,
)
class CrossRoleSelectionPayloadTest(unittest.TestCase):
def test_selection_contains_required_fields(self) -> None:
c = WorkCandidate(
kind="issue",
number=840,
labels=("status:ready",),
title="cross-role",
priority=20,
)
sel = build_selection_dict(
c,
active_role=ROLE_CONTROLLER,
required_role=ROLE_AUTHOR,
profile_name="prgs-controller",
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
)
self.assertEqual(sel["number"], 840)
self.assertEqual(sel["kind"], "issue")
self.assertEqual(sel["required_role"], ROLE_AUTHOR)
self.assertEqual(sel["selected_action"], "implement")
self.assertEqual(sel["action"], "implement")
self.assertEqual(sel["required_profile"], "prgs-author")
self.assertEqual(sel["required_namespace"], "gitea-author")
self.assertEqual(sel["pinned"]["number"], 840)
self.assertIsNone(sel["pinned"]["head_sha"])
def test_profile_prefix_preserved(self) -> None:
self.assertEqual(
required_profile_for_role(ROLE_REVIEWER, profile_name="dadeschools-controller"),
"dadeschools-reviewer",
)
self.assertEqual(
required_namespace_for_role(ROLE_MERGER),
"gitea-merger",
)
def test_selected_actions_per_role(self) -> None:
issue = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
pr_review = WorkCandidate(kind="pr", number=2, head_sha="a" * 40)
pr_rc = WorkCandidate(
kind="pr",
number=3,
head_sha="b" * 40,
request_changes_current_head=True,
)
pr_merge = WorkCandidate(
kind="pr",
number=4,
head_sha="c" * 40,
approval_on_current_head=True,
mergeable=True,
)
pr_recon = WorkCandidate(
kind="pr",
number=5,
head_sha="d" * 40,
approval_contaminated=True,
)
self.assertEqual(selected_action_for_candidate(issue, ROLE_AUTHOR), "implement")
self.assertEqual(
selected_action_for_candidate(pr_rc, ROLE_AUTHOR),
"address_pr_change_requests",
)
self.assertEqual(
selected_action_for_candidate(pr_review, ROLE_REVIEWER), "review"
)
self.assertEqual(selected_action_for_candidate(pr_merge, ROLE_MERGER), "merge")
self.assertEqual(
selected_action_for_candidate(pr_recon, ROLE_RECONCILER),
"reconcile_contaminated_approval",
)
class CrossRoleAllocateServiceTest(unittest.TestCase):
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 _alloc(self, **kwargs):
defaults = dict(
db=self.db,
session_id="ctrl-session",
role=ROLE_CONTROLLER,
remote="prgs",
org="org",
repo="repo",
candidates=[],
apply=False,
profile_name="prgs-controller",
username="controller-bot",
controller_instance_id="ctrl-1",
)
defaults.update(kwargs)
return allocate_next_work(**defaults)
def test_eligible_author_work(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=100,
labels=("status:ready",),
title="author work",
priority=20,
),
]
res = self._alloc(candidates=cands)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
self.assertIsNotNone(res["selected"])
self.assertEqual(res["selected"]["number"], 100)
self.assertEqual(res["required_role"], ROLE_AUTHOR)
self.assertEqual(res["selected_action"], "implement")
self.assertEqual(res["required_profile"], "prgs-author")
self.assertEqual(res["required_namespace"], "gitea-author")
self.assertIn("allocate", res["controller_allowed_actions"])
self.assertIn("merge", res["controller_forbidden_actions"])
self.assertFalse(res["allocation_evidence"]["lease_created"])
def test_eligible_reviewer_work(self) -> None:
cands = [
WorkCandidate(
kind="pr",
number=200,
head_sha="e" * 40,
title="needs review",
priority=30,
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 200)
self.assertEqual(res["required_role"], ROLE_REVIEWER)
self.assertEqual(res["selected_action"], "review")
self.assertEqual(res["required_profile"], "prgs-reviewer")
self.assertEqual(res["selected"]["pinned"]["head_sha"], "e" * 40)
def test_eligible_merger_work(self) -> None:
cands = [
WorkCandidate(
kind="pr",
number=300,
head_sha="f" * 40,
approval_on_current_head=True,
mergeable=True,
priority=40,
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 300)
self.assertEqual(res["required_role"], ROLE_MERGER)
self.assertEqual(res["selected_action"], "merge")
def test_eligible_reconciler_work(self) -> None:
cands = [
WorkCandidate(
kind="pr",
number=400,
head_sha="1" * 40,
approval_contaminated=True,
priority=50,
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 400)
self.assertEqual(res["required_role"], ROLE_RECONCILER)
self.assertIn("reconcile", res["selected_action"])
def test_no_eligible_work(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=10,
labels=("status:blocked",),
blocked=True,
priority=99,
),
WorkCandidate(
kind="issue",
number=11,
labels=("status:ready",),
dependency_unmet=True,
dependency_reason="blocked by #10",
priority=98,
),
]
res = self._alloc(candidates=cands)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
self.assertIsNone(res["selected"])
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
def test_leased_work_skipped(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=50,
labels=("status:ready",),
priority=20,
),
WorkCandidate(
kind="issue",
number=51,
labels=("status:ready",),
priority=10,
),
]
# Seed a foreign lease on issue 50 via assign_and_lease under another session.
other = allocate_next_work(
self.db,
session_id="other-worker",
role=ROLE_AUTHOR,
remote="prgs",
org="org",
repo="repo",
candidates=cands[:1],
apply=True,
profile_name="prgs-author",
controller_instance_id="other-ctrl",
)
self.assertEqual(other["outcome"], "assigned_work")
res = self._alloc(candidates=cands)
self.assertIsNotNone(res["selected"])
self.assertEqual(res["selected"]["number"], 51)
self.assertTrue(any(s["number"] == 50 for s in res["skipped"]))
self.assertTrue(res["claims_excluded"])
def test_dependencies_skipped(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=1,
labels=("status:ready",),
priority=99,
dependency_unmet=True,
dependency_reason="needs #2",
),
WorkCandidate(
kind="issue",
number=2,
labels=("status:ready",),
priority=1,
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 2)
skipped = {s["number"]: s["reason"] for s in res["skipped"]}
self.assertIn(1, skipped)
self.assertIn("needs #2", skipped[1])
def test_pagination_limit_only_truncates_skip_report(self) -> None:
"""Ranking uses full inventory; reporting limit is MCP-layer only.
Service ranks all candidates; prove higher-priority eligible item
wins even when many skipped precede it.
"""
cands = []
for n in range(1, 30):
cands.append(
WorkCandidate(
kind="issue",
number=n,
labels=("status:ready",),
priority=100 - n,
dependency_unmet=True,
dependency_reason=f"dep {n}",
)
)
cands.append(
WorkCandidate(
kind="issue",
number=999,
labels=("status:ready",),
priority=1,
)
)
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 999)
self.assertGreaterEqual(len(res["skipped"]), 29)
def test_role_scoped_controller_legacy_still_restricts(self) -> None:
"""role_scoped controller only takes reconciler-needed items."""
cands = [
WorkCandidate(
kind="issue",
number=1,
labels=("status:ready",),
priority=50,
),
WorkCandidate(
kind="pr",
number=2,
head_sha="a" * 40,
approval_contaminated=True,
priority=1,
),
]
res = self._alloc(
candidates=cands,
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
)
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_ROLE_SCOPED)
self.assertEqual(res["selected"]["number"], 2)
self.assertEqual(res["required_role"], ROLE_RECONCILER)
def test_cross_role_prefers_highest_priority_across_roles(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=10,
labels=("status:ready",),
priority=10,
),
WorkCandidate(
kind="pr",
number=20,
head_sha="b" * 40,
priority=50,
),
WorkCandidate(
kind="pr",
number=30,
head_sha="c" * 40,
approval_on_current_head=True,
mergeable=True,
priority=20,
),
]
res = self._alloc(candidates=cands)
# PR #20 highest priority → reviewer
self.assertEqual(res["selected"]["number"], 20)
self.assertEqual(res["required_role"], ROLE_REVIEWER)
def test_apply_creates_lease_evidence_for_required_role(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=777,
labels=("status:ready",),
priority=20,
),
]
res = self._alloc(candidates=cands, apply=True)
self.assertEqual(res["outcome"], "assigned_work")
self.assertTrue(res["allocation_evidence"]["lease_created"])
self.assertEqual(res["allocation_evidence"]["lease_role"], ROLE_AUTHOR)
proof = res["lease_proof"]
self.assertIsNotNone(proof["lease_id"])
self.assertEqual(proof["lease_role"], ROLE_AUTHOR)
self.assertIn("implement", proof["allowed_actions"])
# Controller isolation: controller still forbids merge/push/create_pr
self.assertIn("merge", res["controller_forbidden_actions"])
self.assertIn("push", res["controller_forbidden_actions"])
def test_metadata_consistency_role_is_controller(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=1,
labels=("status:ready",),
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["role"], ROLE_CONTROLLER)
self.assertEqual(res["routing_role"], ROLE_CONTROLLER)
self.assertEqual(res["required_role"], ROLE_AUTHOR)
class ProcessWorkQueueRouterTest(unittest.TestCase):
def tearDown(self) -> None:
role_session_router.clear_route_state()
def test_process_work_queue_allowed_for_controller(self) -> None:
res = route_task_session(
"process_work_queue",
active_profile="prgs-controller",
active_role_kind="controller",
allowed_in_current_session=True,
)
self.assertEqual(res["route_result"], ROUTE_ALLOWED)
self.assertEqual(res["required_role"], "controller")
self.assertTrue(res["downstream_allowed"])
def test_process_work_queue_hyphen_alias(self) -> None:
res = route_task_session(
"process-work-queue",
active_profile="prgs-controller",
active_role_kind="controller",
allowed_in_current_session=True,
)
self.assertEqual(res["route_result"], ROUTE_ALLOWED)
def test_process_work_queue_wrong_role_for_author(self) -> None:
res = route_task_session(
"process_work_queue",
active_profile="prgs-author",
active_role_kind="author",
allowed_in_current_session=False,
)
self.assertEqual(res["route_result"], ROUTE_WRONG_ROLE)
self.assertEqual(res["required_role"], "controller")
self.assertFalse(res["downstream_allowed"])
def test_unknown_still_ambiguous(self) -> None:
res = route_task_session(
"not_a_real_task",
active_profile="prgs-controller",
active_role_kind="controller",
allowed_in_current_session=False,
)
self.assertEqual(res["route_result"], ROUTE_AMBIGUOUS)
def test_capability_map_process_work_queue_is_controller(self) -> None:
self.assertEqual(
task_capability_map.required_role("process_work_queue"),
"controller",
)
self.assertEqual(
task_capability_map.required_permission("process_work_queue"),
"gitea.read",
)
class ControllerRoleMetadataTest(unittest.TestCase):
def test_normalize_role_kind_controller(self) -> None:
self.assertEqual(
nwb.normalize_role_kind("controller"),
"controller",
)
self.assertEqual(
nwb.normalize_role_kind("author", profile_name="prgs-controller"),
"controller",
)
self.assertEqual(
nwb.normalize_role_kind("reconciler", profile_name="prgs-controller"),
"controller",
)
def test_profile_role_kind_prefers_declared_controller(self) -> None:
# Import from worktree package path via sys.path already set by pytest.
import gitea_mcp_server as mcp
profile = {
"profile_name": "prgs-controller",
"role": "controller",
"allowed_operations": [
"gitea.read",
"gitea.issue.comment",
"gitea.pr.close",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
],
}
# Declared role wins even if permissions look reconciler-like.
self.assertEqual(mcp._profile_role_kind(profile), "controller")
# Name-based fallback.
profile_no_role = dict(profile)
profile_no_role["role"] = None
profile_no_role["role_kind"] = None
self.assertEqual(mcp._profile_role_kind(profile_no_role), "controller")
def test_permission_inference_without_controller_name_stays_reconciler(self) -> None:
import gitea_mcp_server as mcp
# Pure permission inference still may return reconciler when no controller
# declaration exists — that is intentional for reconciler profiles.
role = mcp._role_kind(
["gitea.read", "gitea.pr.close", "gitea.issue.comment"],
["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create", "gitea.branch.push"],
)
self.assertEqual(role, "reconciler")
class DashboardRemainsExplanatoryTest(unittest.TestCase):
def test_dashboard_prompt_points_at_allocator_not_self_select(self) -> None:
import workflow_dashboard as wd
self.assertIn("gitea_allocate_next_work", wd.PROMPT_CONTROLLER)
self.assertIn("process_work_queue", wd.PROMPT_CONTROLLER)
self.assertIn("never replaces allocator", wd.PROMPT_CONTROLLER.lower())
self.assertNotIn("self-select", wd.PROMPT_CONTROLLER.lower())
class ClassifySkipCrossRoleTest(unittest.TestCase):
def test_controller_cross_role_accepts_author_issue(self) -> None:
c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
self.assertIsNone(
classify_skip(
c,
role=ROLE_CONTROLLER,
terminal_pr=None,
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
)
)
def test_legacy_controller_skips_author_issue(self) -> None:
c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
reason = classify_skip(
c,
role=ROLE_CONTROLLER,
terminal_pr=None,
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
)
self.assertIsNotNone(reason)
self.assertIn("does not require controller", reason or "")
if __name__ == "__main__":
unittest.main()
@@ -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,447 @@
"""Exact-owner renewal of an expired author issue lease (#760).
Covers the renewal disposition that lets the exact recorded owner re-acquire
its own lock after the wall-clock lease expires including while the recording
MCP daemon PID is still alive plus every rejection condition that must keep
failing closed, and the pre-existing dead-PID and live-foreign dispositions
that must remain untouched.
"""
import inspect
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import issue_lock_renewal # noqa: E402
import issue_lock_store # noqa: E402
ISSUE = 5150
BRANCH = f"fix/issue-{ISSUE}-demo"
WORKTREE = "/scratch/wt-5150"
HEAD = "c" * 40
OTHER_SHA = "d" * 40
IDENTITY = "example-user"
PROFILE = "example-author"
REMOTE = "prgs"
ORG = "ExampleOrg"
REPO = "ExampleRepo"
def dead_pid() -> int:
"""A PID that has certainly exited (spawned, then reaped)."""
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
return proc.pid
def past_ts(hours: int = 1) -> str:
return (
(datetime.now(timezone.utc) - timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
def future_ts(hours: int = 4) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
def make_lock(*, expires_at: str | None = None, pid: int | None = None, **overrides):
"""An expired lock owned by a still-alive daemon PID — the #760 condition."""
lock = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": WORKTREE,
"remote": REMOTE,
"org": ORG,
"repo": REPO,
# os.getpid() is unambiguously alive: the whole point of #760 is that
# daemon liveness is not evidence of an active author task.
"session_pid": os.getpid() if pid is None else pid,
"lock_generation": 3,
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"branch": BRANCH,
"worktree_path": WORKTREE,
"claimant": {"username": IDENTITY, "profile": PROFILE},
"created_at": past_ts(5),
"expires_at": expires_at or past_ts(),
},
}
lease_overrides = overrides.pop("work_lease", None)
if lease_overrides:
lock["work_lease"].update(lease_overrides)
lock.update(overrides)
return lock
def assess(lock=None, **overrides):
"""Run the assessor with all-passing evidence unless overridden."""
kwargs = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": WORKTREE,
"remote": REMOTE,
"org": ORG,
"repo": REPO,
"identity": IDENTITY,
"profile": PROFILE,
"current_branch": BRANCH,
"porcelain_status": "",
"worktree_exists": True,
"head_sha": HEAD,
"remote_head_sha": HEAD,
"pr_head_sha": None,
"pr_number": None,
"competing_live_locks": [],
"candidate_branches": [BRANCH],
"current_pid": 4242,
}
kwargs.update(overrides)
return issue_lock_renewal.assess_exact_owner_lease_renewal(
make_lock() if lock is None else lock, **kwargs
)
class ExactOwnerRenewalGranted(unittest.TestCase):
"""AC1/AC3-AC7: the positive path."""
def test_expired_lease_alive_pid_exact_owner_is_renewable(self):
result = assess()
self.assertEqual(result["outcome"], issue_lock_renewal.RENEWAL_SANCTIONED)
self.assertTrue(result["renewal_sanctioned"])
self.assertTrue(result["is_candidate"])
def test_renewal_holds_when_owning_pr_head_matches(self):
result = assess(pr_number=999, pr_head_sha=HEAD)
self.assertTrue(result["renewal_sanctioned"])
def test_evidence_records_both_sides_of_the_transition(self):
result = assess()
evidence = result["evidence"]
self.assertEqual(evidence["prior_pid"], os.getpid())
self.assertTrue(evidence["prior_pid_alive"])
self.assertEqual(evidence["replacement_pid"], 4242)
self.assertTrue(evidence["prior_expires_at"])
class ExactOwnerRenewalRefused(unittest.TestCase):
"""AC3-AC8: every near-match must fail closed, one reason at a time."""
def _refused(self, **overrides):
result = assess(**overrides)
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
self.assertFalse(result["renewal_sanctioned"])
self.assertTrue(result["reasons"])
return result
def test_different_branch_refused(self):
result = self._refused(branch_name=f"fix/issue-{ISSUE}-other")
self.assertTrue(any("branch" in r for r in result["reasons"]))
def test_different_worktree_refused(self):
result = self._refused(worktree_path="/scratch/somewhere-else")
self.assertTrue(any("worktree" in r for r in result["reasons"]))
def test_different_claimant_refused(self):
result = self._refused(identity="someone-else")
self.assertTrue(any("claimant" in r for r in result["reasons"]))
def test_different_profile_refused(self):
result = self._refused(profile="other-author")
self.assertTrue(any("profile" in r for r in result["reasons"]))
def test_different_remote_org_or_repo_refused(self):
self._refused(remote="dadeschools")
self._refused(org="OtherOrg")
self._refused(repo="OtherRepo")
def test_dirty_worktree_refused(self):
result = self._refused(porcelain_status=" M gitea_mcp_server.py\n")
self.assertTrue(any("uncommitted" in r for r in result["reasons"]))
def test_missing_worktree_refused(self):
result = self._refused(worktree_exists=False)
self.assertTrue(any("does not exist" in r for r in result["reasons"]))
def test_worktree_on_wrong_branch_refused(self):
self._refused(current_branch="master")
def test_local_and_remote_head_mismatch_refused(self):
result = self._refused(remote_head_sha=OTHER_SHA)
self.assertTrue(
any("does not equal remote head" in r for r in result["reasons"])
)
def test_unpublished_branch_refused(self):
result = self._refused(remote_head_sha=None)
self.assertTrue(any("remote branch head" in r for r in result["reasons"]))
def test_pr_head_mismatch_refused(self):
result = self._refused(pr_number=999, pr_head_sha=OTHER_SHA)
self.assertTrue(any("does not equal local" in r for r in result["reasons"]))
def test_unobservable_pr_head_refused(self):
self._refused(pr_number=999, pr_head_sha=None)
def test_competing_live_lock_on_same_issue_refused(self):
result = self._refused(
competing_live_locks=[
{"issue_number": ISSUE, "branch_name": BRANCH, "pid": 777}
]
)
self.assertTrue(any("live lock" in r for r in result["reasons"]))
def test_competing_live_lock_holding_the_branch_refused(self):
self._refused(
competing_live_locks=[
{"issue_number": 111, "branch_name": BRANCH, "worktree_path": ""}
]
)
def test_competing_branch_claim_refused(self):
result = self._refused(candidate_branches=[BRANCH, f"feat/issue-{ISSUE}-rival"])
self.assertTrue(any("issue marker" in r for r in result["reasons"]))
def test_malformed_durable_lock_refused(self):
lock = make_lock()
lock["worktree_path"] = ""
result = assess(lock)
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
def test_lock_without_recorded_claimant_refused(self):
lock = make_lock()
lock["work_lease"]["claimant"] = {}
result = assess(lock)
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
class NotARenewalCandidate(unittest.TestCase):
"""AC12 and scope: situations renewal must decline to judge at all."""
def test_live_foreign_lease_is_never_a_candidate(self):
lock = make_lock(expires_at=future_ts())
result = assess(lock, identity="someone-else")
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
self.assertFalse(result["renewal_sanctioned"])
def test_unexpired_lease_is_never_a_candidate(self):
lock = make_lock(expires_at=future_ts())
result = assess(lock)
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
def test_dead_pid_under_unexpired_lease_stays_with_753(self):
"""The opposite trigger; #760 must not re-own it."""
lock = make_lock(expires_at=future_ts(), pid=dead_pid())
result = assess(lock)
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
def test_absent_lock_is_not_a_candidate(self):
result = assess({})
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
def test_different_issue_is_not_a_candidate(self):
lock = make_lock()
lock["issue_number"] = ISSUE + 1
result = assess(lock)
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
def test_different_operation_type_is_not_a_candidate(self):
lock = make_lock()
lock["work_lease"]["operation_type"] = "review_pr_work"
result = assess(lock)
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
class DaemonPidIsNotTaskLiveness(unittest.TestCase):
"""AC16: a live recorded PID is never, by itself, authorization."""
def test_alive_pid_alone_does_not_authorize_renewal(self):
# Every ownership fact except the live PID is wrong.
result = assess(identity="someone-else", branch_name="fix/issue-1-nope")
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
self.assertTrue(result["evidence"]["prior_pid_alive"])
def test_renewal_does_not_require_a_dead_pid(self):
result = assess()
self.assertTrue(result["evidence"]["prior_pid_alive"])
self.assertTrue(result["renewal_sanctioned"])
def test_dead_pid_does_not_block_an_otherwise_exact_owner(self):
lock = make_lock(pid=dead_pid())
result = assess(lock)
self.assertTrue(result["renewal_sanctioned"])
class ConflictGateOrdering(unittest.TestCase):
"""AC2: the same-owner allowance is reachable on an expired lease.
These cases need a worktree that genuinely exists on disk. The #601 reclaim
affordance already permits takeover when the recorded worktree is missing,
so a fictional path would satisfy the gate for the wrong reason and never
exercise the ordering defect this issue is about.
"""
@classmethod
def setUpClass(cls):
cls._tmp = tempfile.TemporaryDirectory()
cls.worktree = cls._tmp.name
@classmethod
def tearDownClass(cls):
cls._tmp.cleanup()
def present_lock(self, **overrides):
return make_lock(worktree_path=self.worktree, **overrides)
def test_expired_same_owner_is_allowed_when_renewal_is_sanctioned(self):
block = issue_lock_store.assess_same_issue_lease_conflict(
self.present_lock(),
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.worktree,
renewal_sanctioned=True,
)
self.assertIsNone(block)
def test_expired_same_owner_still_blocks_without_the_waiver(self):
"""Regression for the ordering defect: no waiver, no change in behavior.
Live PID and a present worktree, so the #601 reclaim affordance refuses;
before #760 this was the permanent dead end for an exact owner.
"""
lock = self.present_lock()
self.assertFalse(
issue_lock_store.assess_expired_lock_reclaim(lock)["reclaim_allowed"]
)
block = issue_lock_store.assess_same_issue_lease_conflict(
lock,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.worktree,
)
self.assertIsNotNone(block)
self.assertIn("Recovery review is required", block)
def test_waiver_does_not_unlock_a_different_owner(self):
"""AC11: the waiver is scoped by same_owner, not merely by its own flag."""
block = issue_lock_store.assess_same_issue_lease_conflict(
self.present_lock(),
issue_number=ISSUE,
branch_name=f"fix/issue-{ISSUE}-someone-else",
worktree_path=self.worktree,
renewal_sanctioned=True,
)
self.assertIsNotNone(block)
self.assertIn("Recovery review is required", block)
def test_live_lease_disposition_is_unchanged(self):
"""AC12: a live foreign lease still blocks, waiver or not."""
block = issue_lock_store.assess_same_issue_lease_conflict(
self.present_lock(expires_at=future_ts()),
issue_number=ISSUE,
branch_name=f"fix/issue-{ISSUE}-someone-else",
worktree_path="/scratch/other",
renewal_sanctioned=True,
)
self.assertIsNotNone(block)
self.assertIn("already has an active", block)
def test_dead_pid_reclaim_path_is_unchanged(self):
"""AC11: expired + dead PID still reclaims through the #601 affordance."""
lock = self.present_lock(pid=dead_pid())
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
self.assertTrue(reclaim["reclaim_allowed"])
block = issue_lock_store.assess_same_issue_lease_conflict(
lock,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.worktree,
)
self.assertIsNone(block)
class RenewalRecordAndDownstream(unittest.TestCase):
"""AC9/AC10: durable audit trail, and a renewed lock that actually works."""
def test_record_captures_prior_and_replacement_state(self):
assessment = assess()
record = issue_lock_renewal.build_renewal_record(
assessment,
renewed_at="2026-01-01T00:00:00Z",
new_expires_at="2026-01-01T04:00:00Z",
)
self.assertTrue(record["renewed"])
self.assertEqual(record["prior_pid"], os.getpid())
self.assertEqual(record["new_expires_at"], "2026-01-01T04:00:00Z")
self.assertEqual(record["renewed_at"], "2026-01-01T00:00:00Z")
self.assertEqual(record["identity"], IDENTITY)
self.assertEqual(record["profile"], PROFILE)
self.assertTrue(record["prior_expires_at"])
self.assertTrue(record["proof"])
def test_renewed_lock_satisfies_verify_lock_for_mutation(self):
renewed = make_lock(expires_at=future_ts())
renewed["session_pid"] = os.getpid()
renewed["lease_renewal"] = {"renewed": True}
verdict = issue_lock_store.verify_lock_for_mutation(
renewed,
issue_number=ISSUE,
branch_name=BRANCH,
)
self.assertTrue(verdict["proven"])
self.assertFalse(verdict["block"])
def test_refusal_message_names_the_missing_evidence(self):
assessment = assess(porcelain_status=" M gitea_mcp_server.py\n")
message = issue_lock_renewal.format_renewal_refusal(assessment)
self.assertIn("refused", message)
self.assertIn("uncommitted", message)
class NoCallerControlledRenewalFlag(unittest.TestCase):
"""AC14: renewal eligibility is never declarable by a caller."""
def test_lock_issue_tool_exposes_no_renewal_parameter(self):
import gitea_mcp_server
target = gitea_mcp_server.gitea_lock_issue
target = getattr(target, "fn", getattr(target, "__wrapped__", target))
params = set(inspect.signature(target).parameters)
for forbidden in ("renewal_sanctioned", "renew", "allow_renewal", "is_owner"):
self.assertNotIn(forbidden, params)
def test_store_defaults_to_no_waiver(self):
params = inspect.signature(
issue_lock_store.assess_same_issue_lease_conflict
).parameters
self.assertIs(params["renewal_sanctioned"].default, False)
bind_params = inspect.signature(issue_lock_store.bind_session_lock).parameters
self.assertIs(bind_params["renewal_sanctioned"].default, False)
class NoIssueNumberSpecialCasing(unittest.TestCase):
"""AC17: no repository issue or PR number is special-cased."""
def test_module_contains_no_hardcoded_issue_special_cases(self):
source = inspect.getsource(issue_lock_renewal)
code = "\n".join(
line for line in source.splitlines() if not line.strip().startswith("#")
)
for literal in ("757", "759", "760"):
self.assertNotIn(f"== {literal}", code)
self.assertNotIn(f"issue_number == {literal}", code)
if __name__ == "__main__":
unittest.main()
+342
View File
@@ -0,0 +1,342 @@
"""MCP-level exact-owner lease renewal through ``gitea_lock_issue`` (#760).
The unit suite in ``test_issue_760_exact_owner_lease_renewal`` proves the
renewal *disposition*. It cannot prove the disposition survives the rest of the
tool, and it did not: the waiver was computed and then discarded before
``assess_issue_lock_worktree``, so every real renewal still failed on
base-equivalence. A branch being renewed always carries committed work, so it is
never base-equivalent by construction exactly the argument #753 already makes
for recovery.
These tests drive the public tool end to end against a real git repository and a
real durable lock file, composing every gate in the production order.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import mcp_server # noqa: E402
ISSUE = 9760
BRANCH = f"fix/issue-{ISSUE}-renewal-mcp"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
def _past_ts(hours: int = 1) -> str:
return (
(datetime.now(timezone.utc) - timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
class _RenewalMcpBase(unittest.TestCase):
"""Real git repo + durable expired lock owned by a live PID.
The recorded PID is ``os.getpid()`` unambiguously alive. That is the whole
point of #760: the PID belongs to the long-lived MCP daemon, so its liveness
says nothing about whether the authoring task still holds the work.
"""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.repo = tempfile.mkdtemp(prefix="issue760-mcp-")
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.repo], check=False))
self._init_worktree()
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
def _git(self, *args):
return subprocess.run(
["git", "-C", self.repo, *args],
capture_output=True,
text=True,
check=True,
)
def _init_worktree(self):
self._git("init", "-q", "-b", "master")
self._git("config", "user.email", "[email protected]")
self._git("config", "user.name", "Test")
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
fh.write("seed\n")
self._git("add", "seed.txt")
self._git("commit", "-q", "-m", "seed")
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
# The branch carries committed work, so it is NOT base-equivalent.
self._git("checkout", "-q", "-b", BRANCH)
with open(os.path.join(self.repo, "work.txt"), "w") as fh:
fh.write("author work\n")
self._git("add", "work.txt")
self._git("commit", "-q", "-m", "author work")
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
self.worktree = os.path.realpath(self.repo)
def write_expired_lock(self, **overrides):
path = issue_lock_store.lock_file_path(
remote="prgs",
org=ORG,
repo=REPO,
issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
claimant = {"username": IDENTITY, "profile": PROFILE}
pid = overrides.pop("session_pid", os.getpid())
overrides.pop("pid", None)
lease_overrides = overrides.pop("work_lease", {})
data = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": pid,
"pid": pid,
"lock_generation": 3,
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": BRANCH,
"worktree_path": self.worktree,
"claimant": claimant,
"created_at": _past_ts(5),
"last_heartbeat_at": _past_ts(5),
"expires_at": _past_ts(), # already expired
},
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=claimant,
),
}
data["work_lease"].update(lease_overrides)
data.update(overrides)
data["session_pid"] = pid
data["pid"] = pid
data["lock_file_path"] = path
issue_lock_store.save_lock_file(path, data)
return path
def _tool_env(self):
env = shared_mutation_env(
PROFILE,
include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return env
def _git_state(self, *, porcelain="", branch=BRANCH, head=None):
return {
"current_branch": branch,
"porcelain_status": porcelain,
# The decisive fact: a branch carrying work is never base-equivalent.
"base_equivalent": False,
"head_sha": head or self.head_sha,
"inspected_git_root": self.worktree,
"base_branch": "master",
}
def run_lock_issue(
self,
*,
branch_entries=None,
open_prs=None,
git_state=None,
identity=IDENTITY,
profile=PROFILE,
):
"""Drive the public tool for the published exact-owner renewal shape."""
if branch_entries is None:
branch_entries = [{"name": BRANCH, "commit": {"id": self.head_sha}}]
if open_prs is None:
open_prs = [{"number": 4242, "head": {"ref": BRANCH, "sha": self.head_sha}}]
if git_state is None:
git_state = self._git_state()
env = self._tool_env()
with patch(
"mcp_server.api_get_all", return_value=list(branch_entries)
), patch(
"mcp_server._list_open_pulls", return_value=list(open_prs)
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": identity, "profile": profile},
), patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=git_state,
), patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=lambda h, o, r, auth, issue_number: (
list(open_prs),
[b.get("name") for b in branch_entries if isinstance(b, dict)],
{"status": "not_claimed"},
),
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_lock_issue(
issue_number=ISSUE,
branch_name=BRANCH,
remote="prgs",
worktree_path=self.worktree,
)
class TestRenewalReachableThroughTool(_RenewalMcpBase):
"""F1: the sanctioned renewal must survive every downstream gate."""
def test_expired_lease_live_pid_exact_owner_renews_through_the_tool(self):
prior = issue_lock_store.read_lock_file(self.write_expired_lock())
self.assertTrue(issue_lock_store.is_lease_expired(prior))
self.assertTrue(issue_lock_store.is_process_alive(prior["session_pid"]))
result = self.run_lock_issue()
self.assertTrue(result["success"], result)
self.assertEqual(result["issue_number"], ISSUE)
self.assertEqual(result["branch_name"], BRANCH)
# The renewal is reported natively, so no lock-file inspection is needed.
self.assertIn("lease_renewal", result)
self.assertTrue(result["lease_renewal"]["renewed"])
self.assertIn("Renewed the expired", result["message"])
def test_renewed_lock_records_prior_and_replacement_evidence(self):
prior = issue_lock_store.read_lock_file(self.write_expired_lock())
prior_expiry = prior["work_lease"]["expires_at"]
prior_generation = issue_lock_store.lock_generation(prior)
result = self.run_lock_issue()
written = issue_lock_store.read_lock_file(result["lock_file_path"])
renewal = written["lease_renewal"]
self.assertTrue(renewal["renewed"])
self.assertEqual(renewal["prior_pid"], prior["session_pid"])
self.assertTrue(renewal["prior_pid_alive"])
self.assertEqual(renewal["prior_expires_at"], prior_expiry)
self.assertEqual(renewal["identity"], IDENTITY)
self.assertEqual(renewal["profile"], PROFILE)
self.assertEqual(renewal["head_sha"], self.head_sha)
self.assertTrue(renewal["proof"])
# New expiry is a fresh absolute stamp, later than the one it replaced.
self.assertEqual(renewal["new_expires_at"], written["work_lease"]["expires_at"])
self.assertGreater(renewal["new_expires_at"], prior_expiry)
# Compare-and-swap advanced the generation exactly once.
self.assertEqual(
issue_lock_store.lock_generation(written), prior_generation + 1
)
def test_renewed_lock_is_live_and_satisfies_mutation_ownership(self):
self.write_expired_lock()
result = self.run_lock_issue()
written = issue_lock_store.read_lock_file(result["lock_file_path"])
self.assertTrue(issue_lock_store.assess_lock_freshness(written)["live"])
verdict = issue_lock_store.verify_lock_for_mutation(
written,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.worktree,
)
self.assertTrue(verdict["proven"], verdict)
self.assertFalse(verdict["block"])
def test_recovery_record_is_not_written_for_a_live_owner_renewal(self):
"""#753 recovery must not be claimed when the recorded PID is alive."""
self.write_expired_lock()
result = self.run_lock_issue()
written = issue_lock_store.read_lock_file(result["lock_file_path"])
self.assertNotIn("dead_session_recovery", written)
class TestRenewalWaiverIsNarrow(_RenewalMcpBase):
"""The waiver relaxes base-equivalence and nothing else."""
def test_dirty_worktree_still_blocks_a_would_be_renewal(self):
"""Cleanliness is never waived; the renewal assessor refuses first.
A dirty worktree makes the renewal refuse, so no waiver is issued and
the lease-conflict gate fails closed ahead of the worktree gate. The
refusal names the uncommitted files, so the owner still learns why.
"""
self.write_expired_lock()
with self.assertRaises(Exception) as ctx:
self.run_lock_issue(
git_state=self._git_state(porcelain=" M gitea_mcp_server.py\n")
)
message = str(ctx.exception)
self.assertIn("Recovery review is required before takeover", message)
self.assertIn("worktree has uncommitted tracked changes", message)
self.assertIn("gitea_mcp_server.py", message)
def test_foreign_claimant_cannot_use_the_waiver(self):
"""A near-match owner gets no renewal and no base-equivalence waiver."""
self.write_expired_lock()
with self.assertRaises(Exception) as ctx:
self.run_lock_issue(identity="someone-else")
message = str(ctx.exception)
self.assertIn("Recovery review is required before takeover", message)
# The refusal names the missing ownership evidence (#760 diagnostics).
self.assertIn("does not match active identity", message)
def test_foreign_profile_cannot_use_the_waiver(self):
self.write_expired_lock()
with self.assertRaises(Exception) as ctx:
self.run_lock_issue(profile="other-author")
self.assertIn(
"Recovery review is required before takeover", str(ctx.exception)
)
def test_unpublished_branch_cannot_use_the_waiver(self):
"""No remote head to agree with, so exact-owner renewal is refused."""
self.write_expired_lock()
with self.assertRaises(Exception) as ctx:
self.run_lock_issue(branch_entries=[], open_prs=[])
self.assertIn(
"Recovery review is required before takeover", str(ctx.exception)
)
def test_pr_head_mismatch_cannot_use_the_waiver(self):
self.write_expired_lock()
other = "9" * 40
with self.assertRaises(Exception) as ctx:
self.run_lock_issue(
open_prs=[{"number": 4242, "head": {"ref": BRANCH, "sha": other}}]
)
self.assertIn(
"Recovery review is required before takeover", str(ctx.exception)
)
def test_non_base_equivalent_branch_still_blocks_without_any_waiver(self):
"""No durable lock at all: the ordinary base-equivalence rule applies."""
with self.assertRaises(Exception) as ctx:
self.run_lock_issue()
self.assertIn("must be base-equivalent", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,395 @@
"""Regression coverage for reviewer-lease preflight ordering (#763)."""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
import anti_stomp_preflight
import gitea_mcp_server as server
import merger_lease_adoption
import reviewer_pr_lease
import task_capability_map
def _prime_clean_reviewer_preflight(monkeypatch, resolved_task: str) -> None:
"""Install a clean reviewer preflight without bypassing task matching."""
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
monkeypatch.delenv("GITEA_TEST_FORCE_DIRTY", raising=False)
monkeypatch.setattr(server, "_preflight_in_test_mode", lambda: False)
monkeypatch.setattr(server, "_process_start_porcelain", "")
monkeypatch.setattr(server, "_preflight_whoami_called", False)
monkeypatch.setattr(server, "_preflight_capability_called", False)
monkeypatch.setattr(server, "_preflight_whoami_violation", False)
monkeypatch.setattr(server, "_preflight_capability_violation", False)
monkeypatch.setattr(server, "_preflight_resolved_role", None)
monkeypatch.setattr(server, "_preflight_resolved_task", None)
monkeypatch.setattr(server, "_preflight_whoami_baseline_porcelain", None)
monkeypatch.setattr(server, "_preflight_capability_baseline_porcelain", None)
monkeypatch.setattr(server, "_preflight_whoami_violation_files", [])
monkeypatch.setattr(server, "_preflight_capability_violation_files", [])
monkeypatch.setattr(server, "_preflight_reviewer_violation_files", [])
monkeypatch.setattr(
server,
"_resolve_namespace_mutation_context",
lambda _worktree=None: {
"workspace_path": server.PROJECT_ROOT,
"canonical_repo_root": server.PROJECT_ROOT,
"process_project_root": server.PROJECT_ROOT,
"workspace_role_kind": "reviewer",
"workspace_binding_source": "test reviewer binding",
"ignored_bindings": [],
},
)
monkeypatch.setattr(server, "_enforce_stable_branch_contamination_gate", lambda *_a: None)
monkeypatch.setattr(server, "_enforce_canonical_repository_root", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_enforce_root_checkout_guard", lambda *_a: None)
monkeypatch.setattr(server, "_enforce_branches_only_author_mutation", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_enforce_issue_scope_guard", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_create_issue_bootstrap_assessment", lambda *_a: None)
monkeypatch.setattr(server, "_run_anti_stomp_preflight", lambda *_a, **_k: None)
server.record_preflight_check("whoami")
server.record_preflight_check(
"capability", resolved_role="reviewer", resolved_task=resolved_task
)
def test_documented_review_capability_allows_reviewer_lease_acquire(monkeypatch):
"""whoami -> resolve(review_pr) -> acquire reviewer lease is canonical."""
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
assert server._preflight_capability_called is False
def test_exact_lease_capability_without_intervening_call_still_succeeds(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "acquire_reviewer_pr_lease")
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
assert server._preflight_capability_called is False
def test_missing_wrong_and_consumed_capability_fail_closed(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "create_issue")
with pytest.raises(RuntimeError, match="task mismatch"):
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
_prime_clean_reviewer_preflight(monkeypatch, "acquire_reviewer_pr_lease")
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
with pytest.raises(RuntimeError, match="has not been resolved"):
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
def test_documented_intervening_whoami_read_preserves_capability(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
with patch.object(server, "_get_workspace_porcelain", return_value=""):
server.record_preflight_check("whoami")
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
def test_reviewer_transition_is_narrow_alias_aware_and_one_way():
assert task_capability_map.preflight_task_matches(
"review_pr", "gitea_acquire_reviewer_pr_lease"
)
assert task_capability_map.preflight_task_matches(
"gitea_acquire_reviewer_pr_lease", "acquire_reviewer_pr_lease"
)
assert not task_capability_map.preflight_task_matches(
"acquire_reviewer_pr_lease", "review_pr"
)
assert not task_capability_map.preflight_task_matches(
"review_pr", "acquire_merger_pr_lease"
)
assert not task_capability_map.preflight_task_matches(
"merge_pr", "acquire_reviewer_pr_lease"
)
def test_dirty_reviewer_workspace_still_fails_closed(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
monkeypatch.setenv("GITEA_TEST_PORCELAIN", " M gitea_mcp_server.py\n")
with pytest.raises(RuntimeError, match="Reviewer role violation"):
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
def test_mismatched_reviewer_workspace_still_fails_closed(monkeypatch):
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
monkeypatch.setattr(
server,
"_resolve_namespace_mutation_context",
lambda _worktree=None: {
"workspace_path": "/outside/review-pr-762",
"canonical_repo_root": "/repo",
"process_project_root": "/repo",
"workspace_role_kind": "reviewer",
"workspace_binding_source": "test reviewer binding",
"ignored_bindings": [],
},
)
monkeypatch.setattr(
server.author_mutation_worktree,
"assess_workspace_repo_membership",
lambda **_kwargs: {"block": True, "reasons": ["workspace mismatch"]},
)
monkeypatch.setattr(
server.author_mutation_worktree,
"format_workspace_repo_membership_error",
lambda _assessment: "workspace mismatch (fail closed)",
)
with pytest.raises(RuntimeError, match="workspace mismatch"):
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
def test_reviewer_lease_acquire_requires_workflow_load_proof(monkeypatch):
sha = "a" * 40
monkeypatch.setattr(server, "_anti_stomp_in_test_mode", lambda: False)
monkeypatch.setattr(
server,
"get_profile",
lambda: {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.review",
],
},
)
monkeypatch.setattr(server, "_actual_profile_role", lambda: "reviewer")
monkeypatch.setattr(
server,
"_resolve_namespace_mutation_context",
lambda _worktree=None: {
"workspace_path": "/repo/branches/review-pr-762",
"canonical_repo_root": "/repo",
"process_project_root": "/repo",
},
)
monkeypatch.setattr(
server.issue_lock_worktree,
"read_worktree_git_state",
lambda _path: {
"current_branch": "master",
"head_sha": sha,
"porcelain_status": "",
},
)
monkeypatch.setattr(
server.root_checkout_guard,
"resolve_remote_master_sha",
lambda _path: sha,
)
monkeypatch.setattr(
server,
"_current_master_parity",
lambda: {"startup_head": sha, "current_head": sha},
)
monkeypatch.setattr(server, "_local_git_remote_url", lambda _remote: None)
monkeypatch.setattr(server, "_load_stable_contamination_marker", lambda _remote: None)
monkeypatch.setattr(
server,
"_review_workflow_load_gate_reasons",
lambda: ["canonical review workflow proof missing"],
)
with pytest.raises(RuntimeError, match="workflow"):
server._run_anti_stomp_preflight(
"acquire_reviewer_pr_lease",
remote="prgs",
worktree_path="/repo/branches/review-pr-762",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
def test_whoami_identity_mismatch_invalidates_preflight(monkeypatch):
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
monkeypatch.setattr(server, "_process_start_porcelain", "")
monkeypatch.setattr(server, "_preflight_whoami_called", False)
monkeypatch.setattr(server, "_preflight_capability_called", True)
monkeypatch.setattr(server, "_auth", lambda _host: "redacted")
monkeypatch.setattr(
server,
"api_request",
lambda *_args, **_kwargs: {"login": "wrong-reviewer", "id": 7},
)
monkeypatch.setattr(
server,
"get_profile",
lambda: {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"username": "sysadmin",
"allowed_operations": ["gitea.read", "gitea.pr.review"],
"forbidden_operations": [],
},
)
monkeypatch.setattr(server, "_seed_session_context", lambda **_kwargs: None)
monkeypatch.setattr(server.session_ctx, "mutation_context_audit_fields", lambda: {})
monkeypatch.setattr(server, "_reveal_endpoints", lambda: False)
result = server.gitea_whoami(remote="prgs")
assert result["identity_match"] is False
assert server._preflight_whoami_called is False
assert server._preflight_capability_called is False
def test_denied_reviewer_profile_does_not_leave_capability_proof(monkeypatch):
profile = {
"profile_name": "prgs-author",
"role": "author",
"username": "jcwalker3",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.review",
],
"forbidden_operations": [],
}
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
monkeypatch.setattr(server, "_process_start_porcelain", "")
monkeypatch.setattr(server, "get_profile", lambda: profile)
monkeypatch.setattr(
server.gitea_config,
"load_config",
lambda: {"profiles": {"prgs-author": profile}},
)
monkeypatch.setattr(server.gitea_config, "is_runtime_switching_enabled", lambda: False)
monkeypatch.setattr(server, "_authenticated_username", lambda _host: "jcwalker3")
monkeypatch.setattr(server, "_seed_session_context", lambda **_kwargs: None)
monkeypatch.setattr(
server.session_ctx,
"assess_session_context",
lambda **_kwargs: {"block": False, "reasons": []},
)
monkeypatch.setattr(
server.session_ctx,
"assess_identity_match",
lambda **_kwargs: {"block": False, "reasons": []},
)
monkeypatch.setattr(
server.session_ctx,
"profile_allowed_for_remote",
lambda *_args, **_kwargs: {"block": False, "reasons": []},
)
monkeypatch.setattr(server.session_ctx, "mutation_context_audit_fields", lambda: {})
monkeypatch.setattr(
server.role_session_router,
"assess_infra_stop",
lambda _root: {"infra_stop": False, "infra_stop_reasons": []},
)
monkeypatch.setattr(server, "_check_mcp_runtimes_diagnostics", lambda *_a: [])
monkeypatch.setattr(
server,
"_assess_stale_active_binding",
lambda **_kwargs: {"classification": "unbound"},
)
monkeypatch.setattr(server, "record_mutation_authority", lambda *_args: None)
monkeypatch.setattr(server, "init_review_decision_lock", lambda *_a, **_k: None)
monkeypatch.setattr(server.capability_stop_terminal, "is_active", lambda: False)
monkeypatch.setattr(
server.capability_stop_terminal,
"sync_from_capability_result",
lambda _result: False,
)
result = server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
assert result["allowed_in_current_session"] is False
assert result["required_role_kind"] == "reviewer"
assert server._preflight_capability_called is False
def test_head_and_foreign_lease_protections_remain_enforced():
now = datetime.now(timezone.utc)
head = "a" * 40
moved_head = "b" * 40
body = reviewer_pr_lease.format_lease_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=762,
issue_number=605,
reviewer_identity="other-reviewer",
profile="prgs-reviewer",
session_id="foreign-session",
worktree="/repo/branches/review-pr-762",
phase="claimed",
candidate_head=head,
target_branch="master",
target_branch_sha="c" * 40,
last_activity=now,
)
comments = [{"id": 10, "author": "other-reviewer", "body": body}]
acquire = reviewer_pr_lease.assess_acquire_lease(
comments,
pr_number=762,
reviewer_identity="sysadmin",
profile="prgs-reviewer",
session_id="my-session",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=605,
worktree="/repo/branches/review-pr-762-mine",
candidate_head=head,
target_branch="master",
target_branch_sha="c" * 40,
now=now,
)
assert acquire["acquire_allowed"] is False
reviewer_pr_lease.clear_session_lease()
reviewer_pr_lease.record_session_lease(
{
"pr_number": 762,
"session_id": "foreign-session",
"candidate_head": head,
"comment_id": 10,
},
lease_provenance=merger_lease_adoption.build_lease_provenance(
source=merger_lease_adoption.SOURCE_ACQUIRE,
comment_id=10,
),
)
try:
gate = reviewer_pr_lease.assess_mutation_lease_gate(
pr_number=762,
comments=comments,
reviewer_identity="other-reviewer",
session_id="foreign-session",
mutation="approve",
live_head_sha=moved_head,
pinned_head_sha=head,
now=now,
)
finally:
reviewer_pr_lease.clear_session_lease()
assert gate["block"] is True
assert any("head changed" in reason for reason in gate["reasons"])
def test_reviewer_lease_role_gate_is_not_weakened():
result = anti_stomp_preflight.assess_anti_stomp_preflight(
task="acquire_reviewer_pr_lease",
profile_name="prgs-author",
profile_role="author",
required_role="reviewer",
required_permission="gitea.pr.comment",
allowed_operations=["gitea.read"],
check_repo=False,
check_root_checkout=False,
check_worktree=False,
check_stale_runtime=False,
)
assert result["block"] is True
assert result["blocker_kind"] == anti_stomp_preflight.BLOCKER_WRONG_ROLE
@@ -0,0 +1,551 @@
"""Strict-descendant dead-session recovery (#768).
After a dead author session, a preserved clean remediation commit that strictly
descends from the head recorded at lock time must be recoverable so the author
can publish. Equality alone is still accepted (#753); every other divergence
must keep failing closed.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_recovery # noqa: E402
import issue_lock_store # noqa: E402
import issue_lock_worktree # noqa: E402
import issue_work_duplicate_gate # noqa: E402
ISSUE = 7680
PR_NUMBER = 7681
BRANCH = f"fix/issue-{ISSUE}-descendant-recovery"
WORKTREE = "/scratch/wt-768"
RECORDED = "a" * 40
DESCENDANT = "c" * 40
DIVERGED = "d" * 40
BEHIND = "b" * 40
IDENTITY = "example-user"
PROFILE = "example-author"
def dead_pid() -> int:
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
return proc.pid
def future_ts(hours: int = 4) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
def make_lock(**overrides):
lock = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": WORKTREE,
"remote": "prgs",
"org": "ExampleOrg",
"repo": "ExampleRepo",
"session_pid": dead_pid(),
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"branch": BRANCH,
"worktree_path": WORKTREE,
"claimant": {"username": IDENTITY, "profile": PROFILE},
"expires_at": future_ts(),
},
}
lock.update(overrides)
return lock
def ancestry_ok(
*,
ancestor: str = RECORDED,
descendant: str = DESCENDANT,
is_strict: bool = True,
probe_ok: bool = True,
ancestor_present: bool = True,
reasons: list[str] | None = None,
) -> dict:
return {
"ancestor_sha": ancestor,
"descendant_sha": descendant,
"probe_ok": probe_ok,
"ancestor_present": ancestor_present,
"descendant_present": True,
"is_ancestor": is_strict or ancestor == descendant,
"is_strict_descendant": is_strict,
"proof": f"git merge-base --is-ancestor {ancestor} {descendant} -> exit 0",
"reasons": list(reasons or []),
}
def assess(**overrides):
kwargs = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": WORKTREE,
"remote": "prgs",
"org": "ExampleOrg",
"repo": "ExampleRepo",
"identity": IDENTITY,
"profile": PROFILE,
"current_branch": BRANCH,
"porcelain_status": "",
"head_sha": RECORDED,
"remote_head_sha": RECORDED,
"pr_head_sha": RECORDED,
"pr_number": PR_NUMBER,
"competing_live_locks": [],
"candidate_branches": [BRANCH],
"current_pid": os.getpid(),
"head_ancestry": None,
}
kwargs.update(overrides)
lock = kwargs.pop("lock", None)
return issue_lock_recovery.assess_dead_session_lock_recovery(
make_lock() if lock is None else lock, **kwargs
)
class TestExactHeadRecoveryStillSucceeds(unittest.TestCase):
def test_equal_heads_still_sanctioned(self):
result = assess()
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_EQUAL,
)
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
self.assertEqual(result["evidence"]["accepted_head"], RECORDED)
def test_exact_match_record_carries_relation(self):
record = issue_lock_recovery.build_recovery_record(
assess(), recovered_at="2026-07-20T00:00:00Z"
)
self.assertEqual(record["head_relation"], issue_lock_recovery.HEAD_RELATION_EQUAL)
self.assertEqual(record["recorded_head"], RECORDED)
self.assertEqual(record["accepted_head"], RECORDED)
class TestStrictDescendantRecoverySucceeds(unittest.TestCase):
def test_clean_strict_descendant_recovers(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
self.assertEqual(result["evidence"]["accepted_head"], DESCENDANT)
self.assertIsNotNone(result["evidence"]["ancestry_proof"])
self.assertTrue(
any("strictly descends" in r for r in result["reasons"]),
result["reasons"],
)
def test_pr_still_at_recorded_head_is_ok_for_descendant(self):
# Remediation is local only; open PR still points at the recorded head.
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
def test_recovery_record_names_both_heads_and_proof(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
record = issue_lock_recovery.build_recovery_record(
result, recovered_at="2026-07-20T00:00:00Z"
)
self.assertEqual(record["recorded_head"], RECORDED)
self.assertEqual(record["accepted_head"], DESCENDANT)
self.assertEqual(
record["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
self.assertIn("strictly descends", record["ancestry_proof"] or "")
self.assertEqual(record["prior_session_pid"], result["evidence"]["prior_session_pid"])
self.assertEqual(record["replacement_session_pid"], os.getpid())
class TestDescendantEvidenceReachesPublicationGates(unittest.TestCase):
def _descendant_assessment(self):
return assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
def test_owning_pr_evidence_carries_accepted_head(self):
token = issue_lock_recovery.owning_pr_recovery_evidence(
self._descendant_assessment()
)
self.assertIsNotNone(token)
assert token is not None
self.assertEqual(token["head_sha"], RECORDED)
self.assertEqual(token["accepted_head"], DESCENDANT)
self.assertEqual(token["recorded_head"], RECORDED)
self.assertEqual(
token["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
def test_persisted_lock_rebuilds_owning_pr_evidence(self):
assessment = self._descendant_assessment()
record = issue_lock_recovery.build_recovery_record(
assessment, recovered_at="2026-07-20T00:00:00Z"
)
lock = make_lock(dead_session_recovery=record)
token = issue_lock_recovery.recovered_owning_pr_from_lock(lock)
self.assertIsNotNone(token)
assert token is not None
self.assertEqual(token["pr_number"], PR_NUMBER)
self.assertEqual(token["head_sha"], RECORDED)
self.assertEqual(token["accepted_head"], DESCENDANT)
def test_duplicate_gate_accepts_pr_at_recorded_or_accepted_head(self):
token = issue_lock_recovery.owning_pr_recovery_evidence(
self._descendant_assessment()
)
for live_sha in (RECORDED, DESCENDANT):
with self.subTest(live_sha=live_sha):
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[
{
"number": PR_NUMBER,
"title": f"Closes #{ISSUE}",
"body": f"Closes #{ISSUE}",
"head": {"ref": BRANCH, "sha": live_sha},
}
],
branch_names=[BRANCH],
claim_entry={"status": "unclaimed"},
locked_branch=BRANCH,
phase=issue_work_duplicate_gate.PHASE_COMMIT,
recovered_owning_pr=token,
)
self.assertFalse(gate["block"], gate)
self.assertTrue(gate["owning_pr_recovery_exempted"])
def test_duplicate_gate_still_rejects_foreign_head(self):
token = issue_lock_recovery.owning_pr_recovery_evidence(
self._descendant_assessment()
)
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[
{
"number": PR_NUMBER,
"title": f"Closes #{ISSUE}",
"body": f"Closes #{ISSUE}",
"head": {"ref": BRANCH, "sha": DIVERGED},
}
],
branch_names=[BRANCH],
claim_entry={"status": "unclaimed"},
locked_branch=BRANCH,
phase=issue_work_duplicate_gate.PHASE_COMMIT,
recovered_owning_pr=token,
)
self.assertTrue(gate["block"])
self.assertFalse(gate["owning_pr_recovery_exempted"])
class TestDirtyDescendantRejected(unittest.TestCase):
def test_dirty_descendant_refused(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
porcelain_status=" M issue_lock_recovery.py\n",
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(any("dirty" in r.lower() for r in result["reasons"]))
class TestDivergedAndBehindRejected(unittest.TestCase):
def test_diverged_head_refused(self):
result = assess(
head_sha=DIVERGED,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(
ancestor=RECORDED,
descendant=DIVERGED,
is_strict=False,
reasons=[
f"local head {DIVERGED} does not descend from recorded head "
f"{RECORDED}"
],
),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertIsNone(result["evidence"].get("head_relation"))
self.assertTrue(
any("does not match remote" in r for r in result["reasons"]),
result["reasons"],
)
def test_local_behind_recorded_refused(self):
# merge-base --is-ancestor RECORDED BEHIND is false when BEHIND is ancestor.
result = assess(
head_sha=BEHIND,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(
ancestor=RECORDED,
descendant=BEHIND,
is_strict=False,
reasons=[
f"local head {BEHIND} does not descend from recorded head "
f"{RECORDED}"
],
),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("does not match remote" in r or "not a strict descendant" in r
for r in result["reasons"]),
result["reasons"],
)
class TestUnrelatedAndMalformedAncestryRejected(unittest.TestCase):
def test_missing_ancestry_observation_fails_closed(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=None,
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("ancestry" in r.lower() for r in result["reasons"]),
result["reasons"],
)
def test_mismatched_probe_pair_fails_closed(self):
# Observation for a different commit pair must not authorize this pair.
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(ancestor=DIVERGED, descendant=DESCENDANT),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("not the heads under assessment" in r for r in result["reasons"]),
result["reasons"],
)
def test_rewritten_recorded_head_fails_closed(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(ancestor_present=False, is_strict=False),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("no longer reachable" in r or "rewritten" in r
for r in result["reasons"]),
result["reasons"],
)
def test_failed_probe_fails_closed(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(
probe_ok=False,
is_strict=False,
reasons=["ancestry probe failed with exit 128; ancestry unproven"],
),
)
self.assertFalse(result["recovery_sanctioned"])
def test_pr_head_not_equal_to_recorded_blocks_descendant(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=DIVERGED,
head_ancestry=ancestry_ok(),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("open PR" in r and "does not match" in r for r in result["reasons"]),
result["reasons"],
)
class TestLiveOwnerStillRejected(unittest.TestCase):
def test_live_prior_pid_refused_even_with_descendant_proof(self):
result = assess(
lock=make_lock(session_pid=os.getpid()),
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
self.assertFalse(result["recovery_sanctioned"])
self.assertTrue(
any("still alive" in r or "live" in r.lower() for r in result["reasons"]),
result["reasons"],
)
class TestDiagnosticsIdentifyDisposition(unittest.TestCase):
def test_equal_disposition_named(self):
result = assess()
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_EQUAL,
)
def test_descendant_disposition_named(self):
result = assess(
head_sha=DESCENDANT,
remote_head_sha=RECORDED,
pr_head_sha=RECORDED,
head_ancestry=ancestry_ok(),
)
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
def test_rejected_divergence_has_no_accepted_relation(self):
result = assess(
head_sha=DIVERGED,
remote_head_sha=RECORDED,
head_ancestry=None,
)
self.assertIsNone(result["evidence"].get("head_relation"))
message = issue_lock_recovery.format_recovery_refusal(result)
self.assertIn("fail closed", message)
self.assertIn("does not match remote", message)
class TestReadHeadAncestryRealGit(unittest.TestCase):
def _git(self, repo: str, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", repo, *args],
capture_output=True,
text=True,
check=True,
)
def _init_repo_with_chain(self) -> tuple[str, str, str, str]:
"""Return (repo, parent_sha, child_sha, sibling_sha)."""
repo = tempfile.mkdtemp(prefix="issue-768-ancestry-")
self._git(repo, "init")
self._git(repo, "config", "user.email", "[email protected]")
self._git(repo, "config", "user.name", "Test")
path = Path(repo) / "f.txt"
path.write_text("one\n")
self._git(repo, "add", "f.txt")
self._git(repo, "commit", "-m", "parent")
parent = self._git(repo, "rev-parse", "HEAD").stdout.strip()
path.write_text("two\n")
self._git(repo, "add", "f.txt")
self._git(repo, "commit", "-m", "child")
child = self._git(repo, "rev-parse", "HEAD").stdout.strip()
# Divergent sibling: branch from parent, then unique commit.
self._git(repo, "checkout", "-B", "side", parent)
path.write_text("side\n")
self._git(repo, "add", "f.txt")
self._git(repo, "commit", "-m", "sibling")
sibling = self._git(repo, "rev-parse", "HEAD").stdout.strip()
self._git(repo, "checkout", "-B", "main", child)
return repo, parent, child, sibling
def test_strict_descendant_observation(self):
repo, parent, child, _sibling = self._init_repo_with_chain()
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha=parent, descendant_sha=child
)
self.assertTrue(obs["probe_ok"])
self.assertTrue(obs["ancestor_present"])
self.assertTrue(obs["is_ancestor"])
self.assertTrue(obs["is_strict_descendant"])
self.assertEqual(obs["ancestor_sha"], parent)
self.assertEqual(obs["descendant_sha"], child)
def test_equal_heads_not_strict_descendant(self):
repo, parent, _child, _sibling = self._init_repo_with_chain()
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha=parent, descendant_sha=parent
)
self.assertTrue(obs["probe_ok"])
self.assertTrue(obs["is_ancestor"])
self.assertFalse(obs["is_strict_descendant"])
def test_diverged_not_ancestor(self):
repo, _parent, child, sibling = self._init_repo_with_chain()
# child and sibling share a parent but neither descends from the other.
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha=child, descendant_sha=sibling
)
self.assertTrue(obs["probe_ok"])
self.assertFalse(obs["is_ancestor"])
self.assertFalse(obs["is_strict_descendant"])
def test_missing_sha_fails_closed(self):
repo, _parent, child, _ = self._init_repo_with_chain()
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha="0" * 40, descendant_sha=child
)
self.assertFalse(obs["probe_ok"])
self.assertFalse(obs["ancestor_present"])
def test_end_to_end_real_git_descendant_recovery(self):
repo, parent, child, _sibling = self._init_repo_with_chain()
obs = issue_lock_worktree.read_head_ancestry(
repo, ancestor_sha=parent, descendant_sha=child
)
result = assess(
worktree_path=repo,
head_sha=child,
remote_head_sha=parent,
pr_head_sha=parent,
head_ancestry=obs,
lock=make_lock(worktree_path=repo),
)
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
self.assertEqual(
result["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+635
View File
@@ -0,0 +1,635 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mutation_profile_fixture import install_deterministic_remote_urls # noqa: E402
install_deterministic_remote_urls()
"""#781: sanctioned issue title/body editing, and the documentation drift guard.
Two defects are covered here. The first is that no MCP path could edit an issue
title or body at all, so an authorized correction had to be recorded as a
comment. The second is why nobody noticed: documentation named a tool that was
never registered, and nothing compared the two lists.
"""
import glob
import json
import os
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import anti_stomp_preflight # noqa: E402
import edit_issue # noqa: E402
import mcp_server # noqa: E402
import mcp_tool_inventory # noqa: E402
import task_capability_map # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"edit-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
"forbidden_operations": [],
"allowed_repositories": [
"Scaled-Tech-Consulting/Gitea-Tools",
"Example-Org/Example-Repo",
"913443/eAgenda",
],
"execution_profile": "edit-author",
},
"read-only-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "reader-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read"],
"forbidden_operations": ["gitea.issue.comment"],
"allowed_repositories": [
"Scaled-Tech-Consulting/Gitea-Tools",
"Example-Org/Example-Repo",
"913443/eAgenda",
],
"execution_profile": "read-only-author",
},
},
"rules": {"allow_runtime_switching": False},
}
ISSUE_NUMBER = 9
ORIGINAL_TITLE = "fix(mcp): original title"
ORIGINAL_BODY = "Original body.\n"
NEW_TITLE = "fix(mcp): corrected title"
NEW_BODY = "Corrected body.\n"
def _registered_tool_names() -> set[str]:
manager = mcp_server.mcp._tool_manager
return set((getattr(manager, "_tools", None) or {}).keys())
# ---------------------------------------------------------------------------
# Rule: request validation
# ---------------------------------------------------------------------------
class TestValidateEditRequest(unittest.TestCase):
def test_no_field_is_rejected(self):
with self.assertRaises(ValueError) as ctx:
edit_issue.validate_edit_request()
self.assertIn("At least one field", str(ctx.exception))
def test_blank_title_is_rejected(self):
with self.assertRaises(ValueError) as ctx:
edit_issue.validate_edit_request(title=" ")
self.assertIn("cannot be blank", str(ctx.exception))
def test_non_string_title_is_rejected(self):
with self.assertRaises(ValueError):
edit_issue.validate_edit_request(title=42)
def test_non_string_body_is_rejected(self):
with self.assertRaises(ValueError):
edit_issue.validate_edit_request(body=["not", "a", "string"])
def test_empty_body_is_a_legitimate_edit(self):
self.assertEqual(edit_issue.validate_edit_request(body=""), {"body": ""})
# ---------------------------------------------------------------------------
# Rule: planning against the pre-image
# ---------------------------------------------------------------------------
class TestPlanIssueEdit(unittest.TestCase):
def _current(self, **overrides):
issue = {
"number": ISSUE_NUMBER,
"title": ORIGINAL_TITLE,
"body": ORIGINAL_BODY,
"state": "open",
"labels": [{"name": "type:bug"}, {"name": "mcp"}],
"assignees": [{"login": "author-user"}],
"milestone": {"title": "v1.2.0"},
}
issue.update(overrides)
return issue
def test_title_only_sends_only_the_title(self):
plan = edit_issue.plan_issue_edit(self._current(), title=NEW_TITLE)
self.assertEqual(plan["payload"], {"title": NEW_TITLE})
self.assertEqual(plan["requested_fields"], ["title"])
self.assertFalse(plan["no_op"])
def test_body_only_sends_only_the_body(self):
plan = edit_issue.plan_issue_edit(self._current(), body=NEW_BODY)
self.assertEqual(plan["payload"], {"body": NEW_BODY})
def test_combined_edit_sends_both(self):
plan = edit_issue.plan_issue_edit(
self._current(), title=NEW_TITLE, body=NEW_BODY
)
self.assertEqual(plan["payload"], {"title": NEW_TITLE, "body": NEW_BODY})
self.assertEqual(plan["requested_fields"], ["body", "title"])
def test_identical_content_is_an_explicit_no_op(self):
plan = edit_issue.plan_issue_edit(
self._current(), title=ORIGINAL_TITLE, body=ORIGINAL_BODY
)
self.assertTrue(plan["no_op"])
self.assertEqual(plan["payload"], {})
self.assertTrue(plan["reasons"])
self.assertTrue(plan["safe_next_action"])
def test_partially_unchanged_request_sends_only_the_difference(self):
plan = edit_issue.plan_issue_edit(
self._current(), title=ORIGINAL_TITLE, body=NEW_BODY
)
self.assertFalse(plan["no_op"])
self.assertEqual(plan["payload"], {"body": NEW_BODY})
self.assertEqual(plan["unchanged_fields"], ["title"])
def test_missing_body_is_compared_as_empty(self):
current = self._current()
current.pop("body")
plan = edit_issue.plan_issue_edit(current, body="")
self.assertTrue(plan["no_op"])
def test_preserved_snapshot_captures_untouched_fields(self):
plan = edit_issue.plan_issue_edit(self._current(), title=NEW_TITLE)
before = plan["preserved_before"]
self.assertEqual(before["state"], "open")
self.assertEqual(before["labels"], ["type:bug", "mcp"])
self.assertEqual(before["assignees"], ["author-user"])
self.assertEqual(before["milestone"], "v1.2.0")
# ---------------------------------------------------------------------------
# Rule: pull requests are refused
# ---------------------------------------------------------------------------
class TestAssessIssueTarget(unittest.TestCase):
def test_issue_is_accepted(self):
target = edit_issue.assess_issue_target(
{"number": 9, "title": "t"}, issue_number=9
)
self.assertTrue(target["is_issue"])
self.assertEqual(target["reasons"], [])
def test_pull_request_is_refused_with_a_next_action(self):
target = edit_issue.assess_issue_target(
{"number": 9, "pull_request": {"merged": False}}, issue_number=9
)
self.assertFalse(target["is_issue"])
self.assertTrue(target["is_pull_request"])
self.assertIn("gitea_edit_pr", target["safe_next_action"])
# ---------------------------------------------------------------------------
# Rule: read-after-write verification
# ---------------------------------------------------------------------------
class TestVerifyIssueEdit(unittest.TestCase):
def _plan(self, **kwargs):
current = {
"number": ISSUE_NUMBER,
"title": ORIGINAL_TITLE,
"body": ORIGINAL_BODY,
"state": "open",
"labels": [{"name": "type:bug"}],
"assignees": [],
"milestone": None,
}
return edit_issue.plan_issue_edit(current, **kwargs)
def test_applied_content_verifies(self):
plan = self._plan(title=NEW_TITLE)
observed = {
"title": NEW_TITLE,
"body": ORIGINAL_BODY,
"state": "open",
"labels": [{"name": "type:bug"}],
"assignees": [],
"milestone": None,
}
result = edit_issue.verify_issue_edit(observed, plan=plan)
self.assertTrue(result["verified"])
self.assertTrue(result["preserved_intact"])
self.assertEqual(result["applied"], {"title": NEW_TITLE})
def test_unapplied_content_fails_closed(self):
plan = self._plan(title=NEW_TITLE)
observed = {
"title": ORIGINAL_TITLE,
"state": "open",
"labels": [{"name": "type:bug"}],
}
result = edit_issue.verify_issue_edit(observed, plan=plan)
self.assertFalse(result["verified"])
self.assertEqual(result["mismatches"][0]["field"], "title")
self.assertTrue(result["safe_next_action"])
def test_dropped_label_fails_closed(self):
plan = self._plan(title=NEW_TITLE)
observed = {"title": NEW_TITLE, "state": "open", "labels": []}
result = edit_issue.verify_issue_edit(observed, plan=plan)
self.assertFalse(result["verified"])
self.assertFalse(result["preserved_intact"])
self.assertEqual(result["preserved_changed"][0]["field"], "labels")
def test_changed_state_fails_closed(self):
plan = self._plan(body=NEW_BODY)
observed = {
"body": NEW_BODY,
"state": "closed",
"labels": [{"name": "type:bug"}],
}
result = edit_issue.verify_issue_edit(observed, plan=plan)
self.assertFalse(result["verified"])
self.assertEqual(result["preserved_changed"][0]["field"], "state")
# ---------------------------------------------------------------------------
# Tool: gitea_edit_issue against a fake Gitea
# ---------------------------------------------------------------------------
class _EditIssueToolHarness(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(
mcp_server.REMOTES,
{
"prgs": {
"host": "gitea.example.com",
"org": "Example-Org",
"repo": "Example-Repo",
}
},
)
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self.issue = {
"number": ISSUE_NUMBER,
"title": ORIGINAL_TITLE,
"body": ORIGINAL_BODY,
"state": "open",
"labels": [{"name": "type:bug"}, {"name": "mcp"}],
"assignees": [{"login": "author-user"}],
"milestone": {"title": "v1.2.0"},
"html_url": "https://gitea.example.com/Example-Org/Example-Repo/issues/9",
}
self.calls: list[tuple[str, str]] = []
self.patched_payloads: list[dict] = []
patch("gitea_audit.audit_enabled", return_value=False).start()
patch("mcp_server.get_auth_header", return_value="token author-pass").start()
patch("mcp_server.api_request", side_effect=self._api).start()
self.addCleanup(patch.stopall)
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
self._dir.cleanup()
def _api(self, method, url, auth, payload=None):
self.calls.append((method, url))
if url.endswith("/user"):
return {"login": "author-user"}
if "/issues/" in url:
if method == "GET":
return dict(self.issue)
if method == "PATCH":
self.patched_payloads.append(dict(payload or {}))
self.issue.update(payload or {})
return dict(self.issue)
raise AssertionError(f"unexpected API call: {method} {url}")
def _env(self, profile: str = "edit-author") -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"PYTEST_CURRENT_TEST": os.environ.get(
"PYTEST_CURRENT_TEST", "issue_781_edit_issue"
),
}
def _edit(self, profile: str = "edit-author", **kwargs):
with patch.dict(os.environ, self._env(profile), clear=True):
return mcp_server.gitea_edit_issue(
issue_number=ISSUE_NUMBER, remote="prgs", **kwargs
)
def _patch_methods(self) -> list[str]:
return [method for method, _url in self.calls if method == "PATCH"]
class TestEditIssueSucceeds(_EditIssueToolHarness):
def test_title_only_edit(self):
result = self._edit(title=NEW_TITLE)
self.assertTrue(result["success"])
self.assertTrue(result["verified"])
self.assertEqual(result["changed_fields"], ["title"])
self.assertEqual(self.patched_payloads, [{"title": NEW_TITLE}])
self.assertEqual(self.issue["title"], NEW_TITLE)
self.assertEqual(self.issue["body"], ORIGINAL_BODY)
def test_body_only_edit(self):
result = self._edit(body=NEW_BODY)
self.assertTrue(result["success"])
self.assertEqual(self.patched_payloads, [{"body": NEW_BODY}])
self.assertEqual(self.issue["title"], ORIGINAL_TITLE)
self.assertEqual(self.issue["body"], NEW_BODY)
def test_combined_edit(self):
result = self._edit(title=NEW_TITLE, body=NEW_BODY)
self.assertTrue(result["success"])
self.assertEqual(
self.patched_payloads, [{"title": NEW_TITLE, "body": NEW_BODY}]
)
self.assertEqual(result["applied"], {"title": NEW_TITLE, "body": NEW_BODY})
def test_body_can_be_cleared(self):
result = self._edit(body="")
self.assertTrue(result["success"])
self.assertEqual(self.issue["body"], "")
def test_targets_the_issue_endpoint_never_the_pull_endpoint(self):
self._edit(title=NEW_TITLE)
patched = [url for method, url in self.calls if method == "PATCH"]
self.assertTrue(patched)
for url in patched:
self.assertIn("/issues/", url)
self.assertNotIn("/pulls/", url)
def test_labels_state_assignee_and_milestone_are_provably_unchanged(self):
result = self._edit(title=NEW_TITLE)
proof = result["read_after_write"]
self.assertTrue(proof["preserved_intact"])
self.assertEqual(proof["preserved_before"], proof["preserved_after"])
self.assertEqual(proof["preserved_after"]["labels"], ["type:bug", "mcp"])
self.assertEqual(proof["preserved_after"]["state"], "open")
self.assertEqual(proof["preserved_after"]["assignees"], ["author-user"])
self.assertEqual(proof["preserved_after"]["milestone"], "v1.2.0")
def test_read_after_write_re_reads_the_issue(self):
self._edit(title=NEW_TITLE)
issue_calls = [method for method, url in self.calls if "/issues/" in url]
self.assertEqual(issue_calls, ["GET", "PATCH", "GET"])
class TestEditIssueFailsClosed(_EditIssueToolHarness):
def test_no_op_request_is_rejected_without_a_patch(self):
result = self._edit(title=ORIGINAL_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertTrue(result["no_op"])
self.assertEqual(self._patch_methods(), [])
self.assertTrue(result["reasons"])
self.assertTrue(result["safe_next_action"])
def test_invalid_request_raises_before_any_api_call(self):
with self.assertRaises(ValueError):
self._edit()
self.assertEqual(self.calls, [])
def test_blank_title_raises_before_any_api_call(self):
with self.assertRaises(ValueError):
self._edit(title=" ")
self.assertEqual(self.calls, [])
def test_authorization_failure_blocks_before_any_api_call(self):
result = self._edit(profile="read-only-author", title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("permission_report", result)
self.assertEqual(
result["permission_report"]["missing_permission"],
task_capability_map.required_permission("edit_issue"),
)
self.assertEqual(self.calls, [])
def test_pull_request_target_is_refused_without_a_patch(self):
self.issue["pull_request"] = {"merged": False}
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertEqual(self._patch_methods(), [])
self.assertIn("gitea_edit_pr", result["safe_next_action"])
def test_pre_read_transport_error_is_reported(self):
def boom(method, url, auth, payload=None):
raise RuntimeError("connection reset by peer")
with patch("mcp_server.api_request", side_effect=boom):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertTrue(result["reasons"])
self.assertTrue(result["safe_next_action"])
def test_patch_transport_error_is_reported_not_swallowed(self):
def flaky(method, url, auth, payload=None):
if method == "PATCH":
raise RuntimeError("gitea exploded")
return self._api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=flaky):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("issue edit failed", result["reasons"][0])
self.assertEqual(self.issue["title"], ORIGINAL_TITLE)
def test_read_back_transport_error_reports_an_unverified_edit(self):
state = {"gets": 0}
def flaky(method, url, auth, payload=None):
if method == "GET" and "/issues/" in url:
state["gets"] += 1
if state["gets"] > 1:
raise RuntimeError("read timed out")
return self._api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=flaky):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertTrue(result["performed"])
self.assertFalse(result["verified"])
self.assertTrue(result["safe_next_action"])
def test_unapplied_edit_fails_verification(self):
def sticky(method, url, auth, payload=None):
if method == "PATCH":
self.calls.append((method, url))
# Report success but store nothing.
return dict(self.issue)
return self._api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=sticky):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertTrue(result["performed"])
self.assertFalse(result["verified"])
self.assertEqual(
result["read_after_write"]["mismatches"][0]["field"], "title"
)
def test_edit_that_drops_a_label_fails_verification(self):
def label_eating(method, url, auth, payload=None):
if method == "PATCH":
self.calls.append((method, url))
self.issue.update(payload or {})
self.issue["labels"] = []
return dict(self.issue)
return self._api(method, url, auth, payload)
with patch("mcp_server.api_request", side_effect=label_eating):
result = self._edit(title=NEW_TITLE)
self.assertFalse(result["success"])
self.assertFalse(result["read_after_write"]["preserved_intact"])
# ---------------------------------------------------------------------------
# Registration and gate wiring
# ---------------------------------------------------------------------------
class TestEditIssueRegistrationAndGates(unittest.TestCase):
def test_tool_is_registered(self):
self.assertIn("gitea_edit_issue", _registered_tool_names())
def test_resolver_task_exists_with_author_role(self):
self.assertEqual(
task_capability_map.required_permission("edit_issue"),
"gitea.issue.comment",
)
self.assertEqual(task_capability_map.required_role("edit_issue"), "author")
def test_tool_gate_matches_the_resolver_task(self):
self.assertEqual(
task_capability_map.ISSUE_MUTATION_TOOL_TASKS["gitea_edit_issue"],
"edit_issue",
)
self.assertEqual(
task_capability_map.tool_required_permission("gitea_edit_issue"),
task_capability_map.required_permission("edit_issue"),
)
def test_declared_as_an_anti_stomp_mutation_task(self):
self.assertIn("edit_issue", anti_stomp_preflight.MUTATION_TASKS)
def test_edit_pr_remains_pull_request_only(self):
import inspect
params = inspect.signature(mcp_server.gitea_edit_pr).parameters
self.assertIn("pr_number", params)
self.assertNotIn("issue_number", params)
def test_edit_issue_cannot_change_state_or_labels(self):
import inspect
params = inspect.signature(mcp_server.gitea_edit_issue).parameters
self.assertEqual(
[name for name in params if name in ("title", "body")],
["title", "body"],
)
for forbidden in ("state", "labels", "assignee", "assignees", "milestone"):
self.assertNotIn(forbidden, params)
# ---------------------------------------------------------------------------
# The drift guard itself
# ---------------------------------------------------------------------------
class TestInventoryDriftRule(unittest.TestCase):
def test_missing_markers_fail_closed(self):
with self.assertRaises(ValueError):
mcp_tool_inventory.parse_documented_inventory("no markers here")
def test_documented_but_unregistered_is_drift(self):
result = mcp_tool_inventory.assess_inventory_drift(
["gitea_edit_issue", "gitea_view_issue"], ["gitea_view_issue"]
)
self.assertFalse(result["in_sync"])
self.assertEqual(result["documented_not_registered"], ["gitea_edit_issue"])
self.assertTrue(result["safe_next_action"])
def test_registered_but_undocumented_is_drift(self):
result = mcp_tool_inventory.assess_inventory_drift(
["gitea_view_issue"], ["gitea_view_issue", "gitea_edit_issue"]
)
self.assertFalse(result["in_sync"])
self.assertEqual(result["registered_not_documented"], ["gitea_edit_issue"])
def test_unsorted_inventory_is_drift(self):
result = mcp_tool_inventory.assess_inventory_drift(
["gitea_view_issue", "gitea_edit_issue"],
["gitea_view_issue", "gitea_edit_issue"],
)
self.assertFalse(result["in_sync"])
self.assertFalse(result["sorted"])
def test_module_names_are_not_treated_as_tools(self):
self.assertFalse(mcp_tool_inventory.looks_like_tool_name("gitea_auth"))
self.assertTrue(mcp_tool_inventory.looks_like_tool_name("gitea_view_issue"))
def test_unregistered_doc_reference_is_reported(self):
result = mcp_tool_inventory.assess_doc_references(
{"skills/example.md": {"gitea_edit_issue"}}, ["gitea_view_issue"]
)
self.assertFalse(result["clean"])
self.assertEqual(result["unregistered"][0]["tool"], "gitea_edit_issue")
def test_rendered_block_round_trips(self):
block = mcp_tool_inventory.render_inventory_block(
["gitea_view_issue", "gitea_edit_issue"]
)
self.assertEqual(
mcp_tool_inventory.parse_documented_inventory(block),
["gitea_edit_issue", "gitea_view_issue"],
)
class TestDocumentationMatchesRegistry(unittest.TestCase):
"""The live guard: docs and the registry must not drift apart."""
def test_documented_inventory_equals_registered_tools(self):
doc = REPO_ROOT / mcp_tool_inventory.INVENTORY_DOC_PATH
self.assertTrue(doc.exists(), f"{doc} is missing")
documented = mcp_tool_inventory.parse_documented_inventory(
doc.read_text(encoding="utf-8")
)
result = mcp_tool_inventory.assess_inventory_drift(
documented, _registered_tool_names()
)
self.assertTrue(result["in_sync"], "; ".join(result["reasons"]))
def test_every_tool_named_in_the_skills_is_registered(self):
references: dict[str, set[str]] = {}
pattern = str(REPO_ROOT / "skills" / "**" / "*.md")
paths = glob.glob(pattern, recursive=True)
self.assertTrue(paths, "no skill documents found to check")
for path in paths:
text = Path(path).read_text(encoding="utf-8")
names = mcp_tool_inventory.extract_tool_references(text)
if names:
references[str(Path(path).relative_to(REPO_ROOT))] = names
result = mcp_tool_inventory.assess_doc_references(
references, _registered_tool_names()
)
self.assertTrue(result["clean"], "; ".join(result["reasons"]))
if __name__ == "__main__":
unittest.main()
+722
View File
@@ -0,0 +1,722 @@
"""Tests for durable dependency edges (#784, umbrella #628 scope item 6)."""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import allocator_dependencies
import dependency_graph
import gitea_mcp_server as srv
import mcp_tool_inventory
from control_plane_db import SCHEMA_VERSION, ControlPlaneDB, ControlPlaneError
ISSUE = dependency_graph.WORK_KIND_ISSUE
PR = dependency_graph.WORK_KIND_PR
EDGE_BLOCKED = dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
# Schema as it stood before this change, used to prove a real v3 → v4 migration
# rather than a fresh-database creation dressed up as one.
_V3_SCHEMA = """
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
role TEXT NOT NULL,
profile TEXT,
namespace TEXT,
pid INTEGER,
started_at TEXT NOT NULL,
last_heartbeat_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE work_items (
work_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
remote TEXT NOT NULL,
org TEXT NOT NULL,
repo TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('issue', 'pr')),
number INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 0,
current_head_sha TEXT,
updated_at TEXT NOT NULL,
UNIQUE (remote, org, repo, kind, number)
);
CREATE TABLE leases (
lease_id TEXT PRIMARY KEY,
work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
role TEXT NOT NULL,
phase TEXT NOT NULL DEFAULT 'claimed',
expires_at TEXT NOT NULL,
heartbeat_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE assignments (
assignment_id TEXT PRIMARY KEY,
work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
lease_id TEXT NOT NULL REFERENCES leases(lease_id),
allowed_actions TEXT NOT NULL,
forbidden_actions TEXT NOT NULL,
expected_head_sha TEXT,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL
);
CREATE TABLE terminal_locks (
terminal_lock_id INTEGER PRIMARY KEY AUTOINCREMENT,
remote TEXT NOT NULL,
org TEXT NOT NULL,
repo TEXT NOT NULL,
terminal_pr INTEGER NOT NULL,
review_id TEXT,
decision TEXT,
status TEXT NOT NULL DEFAULT 'active',
cleanup_state TEXT,
created_at TEXT NOT NULL,
UNIQUE (remote, org, repo, terminal_pr)
);
CREATE TABLE events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
work_item_id INTEGER REFERENCES work_items(work_item_id),
event_type TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE incident_links (
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
provider_base_url TEXT NOT NULL DEFAULT '',
provider_org TEXT NOT NULL DEFAULT '',
provider_project TEXT NOT NULL DEFAULT '',
provider_issue_id TEXT NOT NULL,
provider_short_id TEXT,
provider_permalink TEXT,
fingerprint TEXT,
gitea_org TEXT NOT NULL,
gitea_repo TEXT NOT NULL,
gitea_issue_number INTEGER NOT NULL,
linked_pr_numbers TEXT,
first_seen TEXT,
last_seen TEXT,
event_count INTEGER,
status TEXT NOT NULL DEFAULT 'open',
release_resolved_at TEXT,
last_sync_at TEXT,
UNIQUE (provider, provider_base_url, provider_org, provider_project,
provider_issue_id)
);
"""
def _edge_kwargs(**overrides):
base = {
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"source_kind": ISSUE,
"source_number": 784,
"target_kind": ISSUE,
"target_number": 628,
"edge_type": EDGE_BLOCKED,
"state": dependency_graph.STATE_UNMET,
}
base.update(overrides)
return base
class VocabularyTest(unittest.TestCase):
"""AC4, AC5: the edge vocabulary is complete and fails closed."""
def test_all_seven_umbrella_relationship_types_exist(self) -> None:
self.assertEqual(len(dependency_graph.EDGE_TYPES), 7)
for edge_type in (
dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
dependency_graph.EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
dependency_graph.EDGE_MERGE_WAITING_FOR_APPROVAL,
dependency_graph.EDGE_RECONCILIATION_WAITING_FOR_MERGE,
dependency_graph.EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
dependency_graph.EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
):
self.assertIn(edge_type, dependency_graph.EDGE_TYPES)
blocking, completion = dependency_graph.default_conditions(edge_type)
self.assertTrue(blocking and completion)
def test_states_match_the_resolver_partitions(self) -> None:
self.assertEqual(
dependency_graph.EDGE_STATES,
frozenset({"unmet", "met", "unavailable"}),
)
def test_unknown_edge_type_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
dependency_graph.normalize_edge_type("waits_for_vibes")
def test_unknown_state_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeStateError):
dependency_graph.normalize_edge_state("probably_fine")
def test_non_work_endpoint_kind_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
dependency_graph.normalize_work_kind("incident")
def test_evidence_sanitization_strips_credentials_and_urls(self) -> None:
clean = dependency_graph.sanitize_evidence(
{
"token": "abc123",
"authorization": "Bearer xyz",
"note": "fetched from https://gitea.example.invalid/api/v1/x",
"nested": [{"api_key": "k"}, "plain"],
"observed_state": "closed",
}
)
self.assertEqual(clean["token"], dependency_graph.REDACTED)
self.assertEqual(clean["authorization"], dependency_graph.REDACTED)
self.assertNotIn("https://", clean["note"])
self.assertEqual(clean["nested"][0]["api_key"], dependency_graph.REDACTED)
self.assertEqual(clean["observed_state"], "closed")
class SchemaTest(unittest.TestCase):
"""AC1-AC3: schema creation, migration, and idempotence."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
def tearDown(self) -> None:
self._tmp.cleanup()
def _tables(self) -> set[str]:
conn = sqlite3.connect(self.db_path)
try:
return {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
finally:
conn.close()
def _schema_version(self) -> str:
conn = sqlite3.connect(self.db_path)
try:
row = conn.execute(
"SELECT value FROM schema_meta WHERE key = 'schema_version'"
).fetchone()
finally:
conn.close()
return str(row[0]) if row else ""
def test_fresh_database_is_v4_with_the_edge_table(self) -> None:
ControlPlaneDB(self.db_path)
self.assertEqual(SCHEMA_VERSION, 4)
self.assertEqual(self._schema_version(), "4")
self.assertIn("dependency_edges", self._tables())
def _seed_v3(self) -> None:
conn = sqlite3.connect(self.db_path)
try:
conn.executescript(_V3_SCHEMA)
conn.execute(
"INSERT INTO schema_meta(key, value) VALUES ('schema_version', '3')"
)
conn.execute(
"""
INSERT INTO work_items(
remote, org, repo, kind, number, state, priority, updated_at
) VALUES ('prgs', 'O', 'R', 'issue', 601, 'open', 20,
'2026-07-01T00:00:00Z')
"""
)
conn.execute(
"""
INSERT INTO sessions(session_id, role, started_at, last_heartbeat_at)
VALUES ('legacy-session', 'author', '2026-07-01T00:00:00Z',
'2026-07-01T00:00:00Z')
"""
)
conn.execute(
"""
INSERT INTO events(work_item_id, event_type, message, created_at)
VALUES (1, 'legacy', 'kept', '2026-07-01T00:00:00Z')
"""
)
conn.commit()
finally:
conn.close()
def test_v3_database_migrates_in_place_without_losing_rows(self) -> None:
self._seed_v3()
self.assertNotIn("dependency_edges", self._tables())
ControlPlaneDB(self.db_path)
self.assertEqual(self._schema_version(), "4")
self.assertIn("dependency_edges", self._tables())
conn = sqlite3.connect(self.db_path)
try:
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0], 1
)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0], 1
)
self.assertEqual(
conn.execute(
"SELECT message FROM events WHERE event_type = 'legacy'"
).fetchone()[0],
"kept",
)
for table in ("leases", "assignments", "terminal_locks", "incident_links"):
conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
finally:
conn.close()
def test_migration_is_idempotent(self) -> None:
self._seed_v3()
ControlPlaneDB(self.db_path)
db = ControlPlaneDB(self.db_path) # second open re-runs the migration
ControlPlaneDB(self.db_path)
self.assertEqual(self._schema_version(), "4")
conn = sqlite3.connect(self.db_path)
try:
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'dependency_edges'"
).fetchall()
finally:
conn.close()
self.assertEqual(len(tables), 1)
self.assertEqual(db.list_dependency_edges(), [])
class EdgePersistenceTest(unittest.TestCase):
"""AC5-AC10: storage, uniqueness, lookup, scope, audit, redaction."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _events(self) -> list[tuple[str, str]]:
conn = sqlite3.connect(self.db.db_path)
try:
return [
(str(row[0]), str(row[1]))
for row in conn.execute(
"SELECT event_type, message FROM events"
).fetchall()
]
finally:
conn.close()
def test_invalid_values_write_nothing(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
self.db.upsert_dependency_edge(**_edge_kwargs(edge_type="nonsense"))
with self.assertRaises(dependency_graph.InvalidEdgeStateError):
self.db.upsert_dependency_edge(**_edge_kwargs(state="maybe"))
with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
self.db.upsert_dependency_edge(**_edge_kwargs(target_kind="incident"))
self.assertEqual(self.db.list_dependency_edges(), [])
def test_stored_edge_carries_the_full_contract(self) -> None:
edge = self.db.upsert_dependency_edge(
**_edge_kwargs(evidence={"observed_state": "not_closed"})
)
self.assertEqual(edge["source_number"], 784)
self.assertEqual(edge["target_number"], 628)
self.assertEqual(edge["edge_type"], EDGE_BLOCKED)
self.assertEqual(edge["state"], "unmet")
self.assertEqual(edge["blocking_condition"], "target issue is not closed")
self.assertEqual(edge["completion_condition"], "target issue is closed")
self.assertEqual(edge["evidence"], {"observed_state": "not_closed"})
self.assertTrue(edge["created_at"])
self.assertTrue(edge["last_observed_at"])
def test_repeated_upsert_updates_one_row(self) -> None:
first = self.db.upsert_dependency_edge(**_edge_kwargs())
second = self.db.upsert_dependency_edge(
**_edge_kwargs(state="met", evidence={"observed_state": "closed"})
)
self.assertEqual(first["edge_id"], second["edge_id"])
edges = self.db.list_dependency_edges()
self.assertEqual(len(edges), 1)
self.assertEqual(edges[0]["state"], "met")
self.assertEqual(edges[0]["evidence"], {"observed_state": "closed"})
def test_upsert_state_change_is_audited(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs())
self.db.upsert_dependency_edge(**_edge_kwargs()) # unchanged: no event
self.assertEqual(self._events(), [])
self.db.upsert_dependency_edge(**_edge_kwargs(state="met"))
events = self._events()
self.assertEqual(len(events), 1)
self.assertEqual(events[0][0], "dependency_edge_state_change")
self.assertIn("unmet -> met", events[0][1])
def test_reverse_lookup_finds_every_waiter(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs(source_number=784))
self.db.upsert_dependency_edge(**_edge_kwargs(source_number=790))
self.db.upsert_dependency_edge(
**_edge_kwargs(
source_kind=PR,
source_number=791,
edge_type=dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
)
)
self.db.upsert_dependency_edge(
**_edge_kwargs(source_number=792, target_number=999)
)
waiters = self.db.list_dependency_edges(target_kind=ISSUE, target_number=628)
self.assertEqual(
sorted(edge["source_number"] for edge in waiters), [784, 790, 791]
)
def test_forward_lookup_and_state_filter(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs(target_number=628))
self.db.upsert_dependency_edge(**_edge_kwargs(target_number=603, state="met"))
blockers = self.db.list_dependency_edges(source_number=784, state="unmet")
self.assertEqual([edge["target_number"] for edge in blockers], [628])
def test_scope_isolation(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs())
self.db.upsert_dependency_edge(**_edge_kwargs(repo="Other-Repo"))
self.assertEqual(
len(self.db.list_dependency_edges(remote="prgs", repo="Gitea-Tools")), 1
)
self.assertEqual(
len(self.db.list_dependency_edges(remote="prgs", repo="Other-Repo")), 1
)
self.assertEqual(len(self.db.list_dependency_edges(remote="dadeschools")), 0)
def test_observation_records_transition_with_prior_state(self) -> None:
edge = self.db.upsert_dependency_edge(**_edge_kwargs())
updated = self.db.record_dependency_edge_observation(
edge["edge_id"],
state="met",
evidence={"observed_state": "closed"},
detail="target closed by merge",
)
self.assertEqual(updated["prior_state"], "unmet")
self.assertEqual(updated["state"], "met")
self.assertTrue(updated["state_changed"])
events = self._events()
self.assertEqual(len(events), 1)
self.assertIn("unmet -> met", events[0][1])
self.assertIn("target closed by merge", events[0][1])
def test_observation_on_unknown_edge_fails_closed(self) -> None:
with self.assertRaises(ControlPlaneError):
self.db.record_dependency_edge_observation("no-such-edge", state="met")
def test_evidence_never_persists_a_credential_or_endpoint(self) -> None:
self.db.upsert_dependency_edge(
**_edge_kwargs(
evidence={
"token": "super-secret",
"source": "GET https://gitea.example.invalid/api/v1/issues/628",
}
)
)
conn = sqlite3.connect(self.db.db_path)
try:
raw = conn.execute("SELECT evidence FROM dependency_edges").fetchone()[0]
finally:
conn.close()
self.assertNotIn("super-secret", raw)
self.assertNotIn("https://", raw)
stored = json.loads(raw)
self.assertEqual(stored["token"], dependency_graph.REDACTED)
self.assertEqual(
self.db.list_dependency_edges()[0]["evidence"]["token"],
dependency_graph.REDACTED,
)
class ResolutionIngestionTest(unittest.TestCase):
"""AC11, AC12: allocation-run ingestion and write-failure tolerance."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _resolution(self):
# Same call the allocator makes: parse the body, resolve live state.
body = "* Parent: #628 · Depends: #601, #603, #999 · Related: #613"
refs = allocator_dependencies.parse_dependency_refs(body)
live = {601: "closed", 603: "open", 999: None}
return allocator_dependencies.resolve_dependency_state(
refs, lambda n: live[n], subject="issue#784"
)
def test_one_edge_per_reference_with_matching_state(self) -> None:
resolution = self._resolution()
reasons = dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
source_number=784,
resolution=resolution,
observed_by="prgs-author-1234-abcd",
)
self.assertEqual(reasons, [])
edges = {
edge["target_number"]: edge
for edge in self.db.list_dependency_edges(source_number=784)
}
self.assertEqual(sorted(edges), [601, 603, 999])
self.assertEqual(edges[601]["state"], "met")
self.assertEqual(edges[603]["state"], "unmet")
self.assertEqual(edges[999]["state"], "unavailable")
self.assertEqual(edges[999]["evidence"]["observed_state"], "unavailable")
self.assertEqual(
edges[603]["evidence"]["observed_by_session"], "prgs-author-1234-abcd"
)
self.assertEqual(edges[601]["edge_type"], EDGE_BLOCKED)
def test_unavailable_evidence_is_never_recorded_as_met(self) -> None:
resolution = self._resolution()
dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=resolution,
)
met = self.db.list_dependency_edges(state="met")
self.assertEqual([edge["target_number"] for edge in met], [601])
def test_store_write_failure_is_reported_not_raised(self) -> None:
class BrokenStore:
def upsert_dependency_edge(self, **_kwargs):
raise RuntimeError("disk is on fire")
reasons = dependency_graph.record_issue_dependency_edges(
BrokenStore(),
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=self._resolution(),
)
self.assertEqual(len(reasons), 3)
self.assertTrue(all("disk is on fire" in reason for reason in reasons))
def test_no_declared_dependencies_writes_nothing(self) -> None:
resolution = allocator_dependencies.resolve_dependency_state(
(), lambda n: "closed", subject="issue#784"
)
reasons = dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=resolution,
)
self.assertEqual(reasons, [])
self.assertEqual(self.db.list_dependency_edges(), [])
class AllocationRunIngestionTest(unittest.TestCase):
"""AC11, AC12, AC14: the live allocator path writes edges without changing
what it selects."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
@staticmethod
def _issue(number: int, *, body: str = "") -> dict:
return {
"number": number,
"title": f"issue {number}",
"body": body,
"labels": [{"name": "status:ready"}],
"state": "open",
}
def _fake_gitea(self, issues, *, closed=()):
closed_set = set(closed)
def api_get_all(url, _auth, **_kw):
if "/pulls" in url:
return []
return list(issues)
def api_request(_method, url, _auth, **_kw):
number = int(url.rsplit("/", 1)[-1])
state = "closed" if number in closed_set else "open"
return {"number": number, "state": state}
return api_get_all, api_request
def _allocate(self, issues, *, closed=(), db, **kwargs):
api_get_all, api_request = self._fake_gitea(issues, closed=closed)
with patch(
"gitea_mcp_server._profile_operation_gate", return_value=None
), patch(
"gitea_mcp_server._resolve", return_value=("h", "O", "R")
), patch(
"gitea_mcp_server._auth", return_value="token REDACTED"
), patch(
"gitea_mcp_server.get_profile",
return_value={"profile_name": "prgs-author", "role": "author"},
), patch(
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(db, [])
), patch(
"gitea_mcp_server.api_get_all", side_effect=api_get_all
), patch(
"gitea_mcp_server.api_request", side_effect=api_request
), patch(
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
):
return srv.gitea_allocate_next_work(
remote="prgs", org="O", repo="R", role="author", **kwargs
)
def test_live_run_persists_one_edge_per_declared_reference(self) -> None:
issues = [
self._issue(600, body="* Parent: #900 · Depends: #601, #500"),
self._issue(601),
self._issue(602),
]
result = self._allocate(issues, closed={500}, db=self.db)
self.assertTrue(result["success"])
edges = self.db.list_dependency_edges(remote="prgs", org="O", repo="R")
by_target = {edge["target_number"]: edge for edge in edges}
self.assertEqual(sorted(by_target), [500, 601])
self.assertEqual(by_target[601]["state"], "unmet")
self.assertEqual(by_target[500]["state"], "met")
self.assertEqual(by_target[601]["source_number"], 600)
self.assertEqual(
by_target[601]["edge_type"], dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
)
self.assertTrue(by_target[601]["evidence"]["observed_by_session"])
def test_selection_is_unchanged_by_the_store(self) -> None:
issues = [
self._issue(600, body="* Depends: #601"),
self._issue(601),
self._issue(602),
]
class DeadStore:
"""Stands in for a control-plane DB whose edge writes all fail."""
def __init__(self, real):
self._real = real
def __getattr__(self, name):
return getattr(self._real, name)
def upsert_dependency_edge(self, **_kwargs):
raise RuntimeError("edge store unavailable")
healthy = self._allocate(issues, db=self.db)
broken = self._allocate(issues, db=DeadStore(self.db))
self.assertEqual(
healthy["selected"]["number"], broken["selected"]["number"]
)
self.assertEqual(
{s["number"] for s in healthy["skipped"]},
{s["number"] for s in broken["skipped"]},
)
self.assertEqual(healthy["candidate_count"], broken["candidate_count"])
self.assertTrue(broken["success"])
warnings = broken.get("inventory_warnings") or []
self.assertTrue(
any("edge store unavailable" in str(w) for w in warnings),
f"write failure must surface in reasons, got {warnings}",
)
def test_repeated_runs_do_not_duplicate_edges(self) -> None:
issues = [self._issue(600, body="* Depends: #601"), self._issue(601)]
self._allocate(issues, db=self.db)
self._allocate(issues, db=self.db)
self.assertEqual(len(self.db.list_dependency_edges()), 1)
class ListDependencyEdgesToolTest(unittest.TestCase):
"""AC13: the read-only tool is gated and never mutates."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
self.db.upsert_dependency_edge(**_edge_kwargs(org="O", repo="R"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _call(self, *, read_block=None, **kwargs):
with patch(
"gitea_mcp_server._profile_operation_gate", return_value=read_block
), patch(
"gitea_mcp_server._resolve", return_value=("h", "O", "R")
), patch(
"gitea_mcp_server._permission_block_report", return_value={"blocked": True}
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
):
return srv.gitea_list_dependency_edges(remote="prgs", **kwargs)
def test_returns_stored_edges(self) -> None:
result = self._call()
self.assertTrue(result["success"])
self.assertTrue(result["read_only"])
self.assertEqual(result["count"], 1)
self.assertEqual(result["edges"][0]["target_number"], 628)
self.assertEqual(len(result["edge_types"]), 7)
def test_reverse_lookup_filter(self) -> None:
self.assertEqual(self._call(target_number=628)["count"], 1)
self.assertEqual(self._call(target_number=999)["count"], 0)
def test_without_read_permission_it_fails_closed(self) -> None:
result = self._call(read_block=["gitea.read not allowed"])
self.assertFalse(result["success"])
self.assertEqual(result["edges"], [])
self.assertIn("permission_report", result)
def test_invalid_filter_fails_closed(self) -> None:
result = self._call(edge_type="not_a_real_type")
self.assertFalse(result["success"])
self.assertEqual(result["edges"], [])
self.assertTrue(any("fail closed" in r for r in result["reasons"]))
def test_tool_is_documented_in_the_inventory(self) -> None:
"""The #781 drift guard requires a registered tool to be documented."""
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
doc = os.path.join(repo_root, mcp_tool_inventory.INVENTORY_DOC_PATH)
with open(doc, "r", encoding="utf-8") as handle:
documented = mcp_tool_inventory.parse_documented_inventory(handle.read())
self.assertIn("gitea_list_dependency_edges", documented)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+444
View File
@@ -0,0 +1,444 @@
"""Task heartbeat through the native MCP author path (#790 Slice A, AC-N6).
Assessor-level coverage is not sufficient here, and this project has already
paid for learning that: in review #499 on PR #791 the #760 renewal waiver was
computed correctly and then *discarded* at two later gates, so every real
renewal still failed while the unit suite stayed green. AC-N6 exists because of
that, and requires driving the real tools against a real git repository and a
real durable lock file, composing the gates in production order.
These tests therefore call ``gitea_lock_issue`` and
``gitea_heartbeat_issue_lock`` themselves and assert on what lands on disk,
never on an assessor's return value alone.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import lease_policy # noqa: E402
import mcp_server # noqa: E402
ISSUE = 9791
BRANCH = f"fix/issue-{ISSUE}-heartbeat-mcp"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
def _ts(moment: datetime) -> str:
return (
moment.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
class _HeartbeatMcpBase(unittest.TestCase):
"""Real git repo plus a real durable lock, driven through the real tools."""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.repo = tempfile.mkdtemp(prefix="issue790-mcp-")
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.repo], check=False))
self._init_worktree()
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
def _git(self, *args):
return subprocess.run(
["git", "-C", self.repo, *args], capture_output=True, text=True, check=True
)
def _init_worktree(self):
self._git("init", "-q", "-b", "master")
self._git("config", "user.email", "[email protected]")
self._git("config", "user.name", "Test")
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
fh.write("seed\n")
self._git("add", "seed.txt")
self._git("commit", "-q", "-m", "seed")
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
# A fresh claim starts base-equivalent, which is the ordinary first-lock
# shape and exercises assess_issue_lock_worktree on its normal path.
self._git("checkout", "-q", "-b", BRANCH)
self.head_sha = self.base_sha
self.worktree = os.path.realpath(self.repo)
def _lock_path(self):
return issue_lock_store.lock_file_path(
remote="prgs",
org=ORG,
repo=REPO,
issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def _tool_env(self):
env = shared_mutation_env(
PROFILE, include_example_repo=True, GITEA_ISSUE_LOCK_DIR=self.lock_dir.name
)
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return env
def _git_state(self, *, porcelain="", base_equivalent=True):
return {
"current_branch": BRANCH,
"porcelain_status": porcelain,
"base_equivalent": base_equivalent,
"head_sha": self.head_sha,
"inspected_git_root": self.worktree,
"base_branch": "master",
}
def run_lock_issue(
self,
*,
branch_entries=None,
open_prs=None,
git_state=None,
identity=IDENTITY,
profile=PROFILE,
):
branch_entries = branch_entries if branch_entries is not None else []
open_prs = open_prs if open_prs is not None else []
git_state = git_state or self._git_state()
env = self._tool_env()
with patch(
"mcp_server.api_get_all", return_value=list(branch_entries)
), patch(
"mcp_server._list_open_pulls", return_value=list(open_prs)
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": identity, "profile": profile},
), patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=git_state,
), patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=lambda h, o, r, auth, issue_number: (
list(open_prs),
[b.get("name") for b in branch_entries if isinstance(b, dict)],
{"status": "not_claimed"},
),
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_lock_issue(
issue_number=ISSUE,
branch_name=BRANCH,
remote="prgs",
worktree_path=self.worktree,
)
def run_heartbeat(
self, *, task_session_id, identity=IDENTITY, profile=PROFILE, **kwargs
):
env = self._tool_env()
with patch(
"mcp_server._work_lease_claimant",
return_value={"username": identity, "profile": profile},
), patch("mcp_server.get_auth_header", return_value="token x"), patch.dict(
os.environ, env, clear=True
):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_heartbeat_issue_lock(
issue_number=ISSUE,
branch_name=kwargs.pop("branch_name", BRANCH),
task_session_id=task_session_id,
remote="prgs",
worktree_path=kwargs.pop("worktree_path", self.worktree),
**kwargs,
)
def write_legacy_lock(self, *, hours_old: float = 3.0, ttl_hours: float = 4.0):
"""A durable lock in the shape the store wrote before this slice."""
now = datetime.now(timezone.utc)
claimant = {"username": IDENTITY, "profile": PROFILE}
created = now - timedelta(hours=hours_old)
record = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": os.getpid(),
"pid": os.getpid(),
"lock_generation": 1,
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": BRANCH,
"worktree_path": self.worktree,
"claimant": claimant,
"created_at": _ts(created),
# The legacy signature: never advanced past creation.
"last_heartbeat_at": _ts(created),
"expires_at": _ts(created + timedelta(hours=ttl_hours)),
},
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue", claimant=claimant
),
}
path = self._lock_path()
record["lock_file_path"] = path
issue_lock_store.save_lock_file(path, record)
return record
class TestLockIssueMintsTheLifecycle(_HeartbeatMcpBase):
"""Durable lock creation and read-back through the real tool."""
def test_native_lock_writes_the_marker_and_a_task_session_id(self):
result = self.run_lock_issue()
self.assertTrue(result["success"], result)
written = issue_lock_store.read_lock_file(result["lock_file_path"])
lease = written["work_lease"]
self.assertEqual(
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
)
self.assertTrue(lease["task_session_id"])
self.assertFalse(issue_lock_store.is_legacy_lease(written))
# AC-N1: the ownership key is not the daemon pid, which is recorded
# separately as evidence.
self.assertNotIn(str(written["session_pid"]), lease["task_session_id"])
self.assertEqual(written["session_pid"], os.getpid())
def test_native_lease_uses_the_policy_window_not_four_hours(self):
result = self.run_lock_issue()
lease = result["work_lease"]
created = datetime.fromisoformat(lease["created_at"].replace("Z", "+00:00"))
expires = datetime.fromisoformat(lease["expires_at"].replace("Z", "+00:00"))
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
self.assertEqual(
(expires - created).total_seconds() / 60.0, policy.initial_ttl_minutes
)
def test_freshness_of_a_new_native_lock_is_live(self):
result = self.run_lock_issue()
self.assertEqual(
result["lock_freshness"]["status"], issue_lock_store.STATUS_LIVE
)
self.assertTrue(result["lock_freshness"]["live"])
class TestHeartbeatThroughTheTool(_HeartbeatMcpBase):
def _lock_and_session(self):
result = self.run_lock_issue()
self.assertTrue(result["success"], result)
return result, result["work_lease"]["task_session_id"]
def test_heartbeat_slides_the_lease_and_advances_the_generation(self):
locked, session = self._lock_and_session()
before = issue_lock_store.read_lock_file(locked["lock_file_path"])
beat = self.run_heartbeat(task_session_id=session)
self.assertTrue(beat["success"], beat)
self.assertEqual(beat["operation"], "heartbeat")
after = issue_lock_store.read_lock_file(locked["lock_file_path"])
self.assertGreater(
issue_lock_store.lock_generation(after),
issue_lock_store.lock_generation(before),
)
self.assertGreaterEqual(
after["work_lease"]["expires_at"], before["work_lease"]["expires_at"]
)
self.assertEqual(after["work_lease"]["heartbeat_count"], 2)
def test_heartbeat_evidence_survives_the_downstream_mutation_gate(self):
"""The #499 F2 lesson, applied.
A sanction that is computed and then discarded downstream is worthless.
After a heartbeat the lock must still satisfy the gate every author
mutation runs through.
"""
locked, session = self._lock_and_session()
self.run_heartbeat(task_session_id=session)
written = issue_lock_store.read_lock_file(locked["lock_file_path"])
verdict = issue_lock_store.verify_lock_for_mutation(
written,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.worktree,
)
self.assertTrue(verdict["proven"], verdict)
self.assertFalse(verdict["block"])
def _duplicate_gate(self, *, open_prs, branches):
env = self._tool_env()
with patch("mcp_server.get_auth_header", return_value="token x"), patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=lambda h, o, r, auth, issue_number: (
list(open_prs),
list(branches),
{"status": "not_claimed"},
),
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_assess_work_issue_duplicate(
issue_number=ISSUE, branch_name=BRANCH, remote="prgs"
)
def test_heartbeat_does_not_change_the_duplicate_gate_verdict(self):
"""The gate must be invariant under heartbeating.
The point is not that the gate passes with a linked open PR at the
lock phase it correctly blocks (#400), heartbeat or not. The property
that matters is that sliding a lease neither loosens the gate nor
corrupts the lock state it reads: the verdict before and after a
heartbeat must be identical, for both the clear and the blocking shape.
"""
_, session = self._lock_and_session()
linked = [{"number": 4242, "head": {"ref": BRANCH, "sha": self.head_sha}}]
clear_before = self._duplicate_gate(open_prs=[], branches=[])
blocked_before = self._duplicate_gate(open_prs=linked, branches=[BRANCH])
self.assertTrue(self.run_heartbeat(task_session_id=session)["success"])
clear_after = self._duplicate_gate(open_prs=[], branches=[])
blocked_after = self._duplicate_gate(open_prs=linked, branches=[BRANCH])
self.assertEqual(clear_before["outcome"], clear_after["outcome"])
self.assertFalse(clear_after["block"])
self.assertEqual(blocked_before["outcome"], blocked_after["outcome"])
self.assertTrue(blocked_after["block"])
self.assertEqual(blocked_after["linked_open_pr"], 4242)
def test_foreign_session_id_is_refused_through_the_tool(self):
self._lock_and_session()
beat = self.run_heartbeat(task_session_id="author_issue_work-ffffffffffffffff")
self.assertFalse(beat["success"])
self.assertIn("task_session_id does not match", " ".join(beat["reasons"]))
def test_stale_generation_is_refused_through_the_tool(self):
locked, session = self._lock_and_session()
current = issue_lock_store.lock_generation(
issue_lock_store.read_lock_file(locked["lock_file_path"])
)
beat = self.run_heartbeat(
task_session_id=session, expected_generation=current + 5
)
self.assertFalse(beat["success"])
self.assertIn("generation changed", beat["reasons"][0])
def test_foreign_claimant_is_refused_through_the_tool(self):
_, session = self._lock_and_session()
beat = self.run_heartbeat(task_session_id=session, identity="someone-else")
self.assertFalse(beat["success"])
def test_heartbeat_cannot_acquire_a_missing_lock(self):
beat = self.run_heartbeat(task_session_id="author_issue_work-000000000000")
self.assertFalse(beat["success"])
self.assertIn("no durable lock", beat["reasons"][0])
def test_alive_pid_alone_does_not_keep_a_lease_live_through_the_tool(self):
"""PID-only refusal, end to end.
The recorded pid is this live process. The lock is aged past its grace
with no heartbeat, so the tool must refuse to slide it and the durable
record must classify as a missed heartbeat rather than as live.
"""
locked, session = self._lock_and_session()
record = issue_lock_store.read_lock_file(locked["lock_file_path"])
record["work_lease"]["last_heartbeat_at"] = _ts(
datetime.now(timezone.utc) - timedelta(minutes=30)
)
record["work_lease"]["expires_at"] = _ts(
datetime.now(timezone.utc) + timedelta(hours=2)
)
issue_lock_store.save_lock_file(locked["lock_file_path"], record)
self.assertTrue(issue_lock_store.is_process_alive(record["session_pid"]))
fresh = issue_lock_store.assess_lock_freshness(record)
self.assertEqual(
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
)
self.assertTrue(fresh["pid_alive"])
beat = self.run_heartbeat(task_session_id=session)
self.assertFalse(beat["success"])
self.assertIn("reclaimed", " ".join(beat["reasons"]))
class TestLegacyLocksThroughTheTool(_HeartbeatMcpBase):
"""AC-N8 end to end: protected on deployment, and rebindable."""
def test_legacy_lock_stays_protected_after_deployment(self):
record = self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
fresh = issue_lock_store.assess_lock_freshness(record)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
self.assertTrue(fresh["legacy_lease"])
self.assertTrue(fresh["legacy_expiry_preserved"])
# It had never heartbeated, so under the new grace alone it would be
# long gone; the preserved absolute expiry is what protects it.
self.assertEqual(
record["work_lease"]["created_at"],
record["work_lease"]["last_heartbeat_at"],
)
def test_tool_rebinds_a_legacy_lock_and_mints_a_first_heartbeat(self):
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
result = self.run_heartbeat(task_session_id=None)
self.assertTrue(result["success"], result)
self.assertEqual(result["operation"], "legacy_rebind")
self.assertTrue(result["task_session_id"])
written = issue_lock_store.read_lock_file(self._lock_path())
lease = written["work_lease"]
self.assertEqual(
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
)
self.assertEqual(lease["heartbeat_count"], 1)
self.assertNotEqual(
lease["created_at"],
written["legacy_rebind"]["legacy_origin"]["created_at"],
)
self.assertFalse(issue_lock_store.is_legacy_lease(written))
def test_rebound_lock_then_heartbeats_through_the_tool(self):
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
rebound = self.run_heartbeat(task_session_id=None)
beat = self.run_heartbeat(task_session_id=rebound["task_session_id"])
self.assertTrue(beat["success"], beat)
self.assertEqual(beat["operation"], "heartbeat")
self.assertEqual(beat["heartbeat_count"], 2)
def test_rebind_refuses_a_foreign_owner_through_the_tool(self):
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
result = self.run_heartbeat(task_session_id=None, identity="someone-else")
self.assertFalse(result["success"])
self.assertEqual(result["operation"], "legacy_rebind")
if __name__ == "__main__":
unittest.main()
+594
View File
@@ -0,0 +1,594 @@
"""Central lease policy and load-bearing heartbeat freshness (#790 Slice A).
Before this slice, ``issue_lock_store.assess_lock_freshness`` parsed
``last_heartbeat_at`` and then never consulted it: liveness was decided by an
absolute four-hour ``expires_at`` and by PID liveness. Because the recorded PID
is the long-lived MCP daemon rather than the authoring task, an abandoned claim
stayed "live" for the full four hours, and a claim whose work had already landed
blocked reconciliation for just as long (Issue #787 / PR #789, and again Issue
#760 / PR #791).
These tests pin the corrected semantics, including the two asymmetries that are
easy to lose in a refactor:
* an **alive** PID must never make anything live (AC-N2), while
* a **dead** PID must still mark a lease stale, because #753 dead-session
recovery keys on exactly that classification.
Durable-state helpers here write real lock files through the real flock path;
they are not mocks of the store.
"""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import issue_lock_store # noqa: E402
import lease_policy # noqa: E402
import pr_work_lease # noqa: E402
import reviewer_pr_lease # noqa: E402
ISSUE = 9790
BRANCH = f"fix/issue-{ISSUE}-heartbeat"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Example-Org"
REPO = "Example-Repo"
REMOTE = "prgs"
DEAD_PID = 2**22 # far above any live pid on a test host
def _ts(moment: datetime) -> str:
return (
moment.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
class _LockFixture(unittest.TestCase):
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.now = datetime.now(timezone.utc)
self.worktree = os.path.realpath(tempfile.mkdtemp(prefix="issue790-"))
self.addCleanup(patch.stopall)
def _path(self):
return issue_lock_store.lock_file_path(
remote=REMOTE,
org=ORG,
repo=REPO,
issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def write_lock(
self,
*,
lifecycle: str | None = lease_policy.LIFECYCLE_HEARTBEAT_V1,
created_delta: timedelta = timedelta(minutes=1),
heartbeat_delta: timedelta = timedelta(minutes=1),
expires_delta: timedelta = timedelta(minutes=9),
pid: int | None = None,
task_session_id: str | None = "author_issue_work-aaaabbbbccccdddd",
generation: int = 1,
identity: str = IDENTITY,
profile: str = PROFILE,
branch: str = BRANCH,
worktree: str | None = None,
) -> dict:
"""Write a real durable lock and return the record.
Deltas are relative to ``self.now``; ``expires_delta`` is added, the
others subtracted, so "in the past" reads naturally at each call site.
"""
lease: dict = {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": branch,
"worktree_path": worktree or self.worktree,
"claimant": {"username": identity, "profile": profile},
"created_at": _ts(self.now - created_delta),
"last_heartbeat_at": _ts(self.now - heartbeat_delta),
"expires_at": _ts(self.now + expires_delta),
}
if lifecycle is not None:
lease["lifecycle_version"] = lifecycle
if task_session_id is not None:
lease["task_session_id"] = task_session_id
pid_value = os.getpid() if pid is None else pid
record = {
"issue_number": ISSUE,
"branch_name": branch,
"remote": REMOTE,
"org": ORG,
"repo": REPO,
"worktree_path": worktree or self.worktree,
"session_pid": pid_value,
"pid": pid_value,
"lock_generation": generation,
"work_lease": lease,
}
path = self._path()
record["lock_file_path"] = path
issue_lock_store.save_lock_file(path, record)
return record
class TestPolicyIsTheSingleSource(unittest.TestCase):
"""AC-N7: one authoritative configuration source for every duration."""
def test_author_policy_carries_the_agreed_values(self):
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
self.assertEqual(policy.initial_ttl_minutes, 10.0)
self.assertEqual(policy.heartbeat_cadence_minutes, 2.0)
self.assertEqual(policy.stale_warning_minutes, 5.0)
self.assertEqual(policy.missed_heartbeat_grace_minutes, 10.0)
self.assertEqual(policy.absolute_cap_hours, 8.0)
self.assertEqual(policy.recovery_grace_minutes, 10.0)
self.assertEqual(policy.terminal_race_drain_minutes, 2.0)
self.assertTrue(policy.terminal_retirement_eligible)
self.assertTrue(policy.heartbeat_lifecycle_active)
def test_the_four_hour_author_ttl_literal_is_gone(self):
"""The duplicated literal AC-N7 exists to remove."""
self.assertFalse(hasattr(issue_lock_store, "WORK_LEASE_TTL_HOURS"))
import gitea_mcp_server
self.assertFalse(hasattr(gitea_mcp_server, "WORK_LEASE_TTL_HOURS"))
def test_declared_reviewer_values_match_the_module_still_using_them(self):
"""Slice A declares reviewer/merger numbers without rewiring them.
Recording a value in two places is only safe if drift is detectable, so
this asserts the declaration still equals the constants #747 owns. When
Slice C migrates those call sites, this test becomes the proof the
migration changed nothing.
"""
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_REVIEWER_PR)
self.assertEqual(
policy.initial_ttl_minutes, float(reviewer_pr_lease.LEASE_TTL_MINUTES)
)
self.assertEqual(
policy.stale_warning_minutes,
float(reviewer_pr_lease.STALE_WARNING_MINUTES),
)
self.assertFalse(policy.heartbeat_lifecycle_active)
def test_declared_conflict_fix_value_matches_its_module(self):
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_CONFLICT_FIX)
self.assertEqual(
policy.initial_ttl_minutes,
float(pr_work_lease.DEFAULT_CONFLICT_FIX_TTL_MINUTES),
)
self.assertFalse(policy.heartbeat_lifecycle_active)
def test_environment_override_applies(self):
var = lease_policy.env_var_name(
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK, "initial_ttl_minutes"
)
with patch.dict(os.environ, {var: "7"}):
self.assertEqual(
lease_policy.policy_for(
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK
).initial_ttl_minutes,
7.0,
)
def test_unusable_override_falls_back_instead_of_minting_a_zero_lease(self):
"""A typo must not make every claim instantly reclaimable."""
var = lease_policy.env_var_name(
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK, "initial_ttl_minutes"
)
for bad in ("0", "-5", "not-a-number", " "):
with self.subTest(value=bad), patch.dict(os.environ, {var: bad}):
self.assertEqual(
lease_policy.policy_for(
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK
).initial_ttl_minutes,
10.0,
)
def test_unknown_task_class_does_not_raise(self):
policy = lease_policy.policy_for("something-new")
self.assertEqual(policy.task_class, lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
class TestLifecycleDiscrimination(_LockFixture):
"""AC-N8: the marker, never a timestamp, decides legacy vs heartbeat."""
def test_missing_marker_reads_as_legacy(self):
record = self.write_lock(lifecycle=None)
self.assertTrue(issue_lock_store.is_legacy_lease(record))
self.assertEqual(
issue_lock_store.lease_lifecycle_version(record),
lease_policy.LIFECYCLE_LEGACY,
)
def test_marker_present_reads_as_heartbeat_lifecycle(self):
record = self.write_lock()
self.assertFalse(issue_lock_store.is_legacy_lease(record))
def test_equal_created_and_heartbeat_never_implies_a_fresh_heartbeat(self):
"""The exact inversion AC-N8 forbids.
A legacy lock has ``last_heartbeat_at == created_at`` forever because
nothing ever advanced it. Reading that equality as "recently
heartbeated" would classify every never-heartbeated lock as fresh.
"""
legacy = self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
)
lease = legacy["work_lease"]
self.assertEqual(lease["created_at"], lease["last_heartbeat_at"])
self.assertTrue(issue_lock_store.is_legacy_lease(legacy))
# A brand-new heartbeat lease has them equal too, so the equality
# carries no information in either direction.
fresh = self.write_lock(
created_delta=timedelta(seconds=0), heartbeat_delta=timedelta(seconds=0)
)
self.assertEqual(
fresh["work_lease"]["created_at"],
fresh["work_lease"]["last_heartbeat_at"],
)
self.assertFalse(issue_lock_store.is_legacy_lease(fresh))
def test_minted_session_id_contains_no_pid(self):
"""AC-N1: the ownership key must not be derived from the daemon pid."""
minted = issue_lock_store.mint_task_session_id()
self.assertNotIn(str(os.getpid()), minted)
self.assertNotEqual(minted, issue_lock_store.mint_task_session_id())
class TestFreshnessIsHeartbeatDriven(_LockFixture):
"""AC-N2 and the new bands."""
def test_fresh_heartbeat_is_live(self):
record = self.write_lock(heartbeat_delta=timedelta(minutes=1))
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
self.assertTrue(fresh["live"])
self.assertFalse(fresh["heartbeat_warning"])
def test_heartbeat_past_warning_is_still_live_but_flagged(self):
record = self.write_lock(heartbeat_delta=timedelta(minutes=6))
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
self.assertTrue(fresh["heartbeat_warning"])
def test_missed_heartbeat_past_grace_is_classified_explicitly(self):
record = self.write_lock(
heartbeat_delta=timedelta(minutes=11),
expires_delta=timedelta(minutes=30),
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
)
self.assertFalse(fresh["live"])
self.assertTrue(fresh["stale"])
def test_alive_pid_never_establishes_freshness(self):
"""The defect in one assertion.
The recorded PID is this very process, so it is unambiguously alive
and the lease is still not live, because the task stopped heartbeating.
"""
record = self.write_lock(
pid=os.getpid(),
heartbeat_delta=timedelta(hours=4),
expires_delta=timedelta(hours=4),
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertTrue(fresh["pid_alive"])
self.assertFalse(fresh["live"])
self.assertEqual(
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
)
def test_dead_pid_still_marks_stale_for_issue_753(self):
"""The opposite asymmetry: dead-PID corroboration is preserved."""
record = self.write_lock(pid=DEAD_PID, heartbeat_delta=timedelta(minutes=1))
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_STALE)
self.assertFalse(fresh["live"])
self.assertIn("not alive", fresh["reason"])
def test_absolute_cap_requires_readoption(self):
record = self.write_lock(
created_delta=timedelta(hours=9), heartbeat_delta=timedelta(minutes=1)
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_STALE_ABSOLUTE_CAP)
self.assertIn("re-adoption", fresh["reason"])
def test_heartbeat_lifecycle_without_a_heartbeat_fails_closed(self):
record = self.write_lock()
del record["work_lease"]["last_heartbeat_at"]
issue_lock_store.save_lock_file(self._path(), record)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
)
self.assertIn("fail closed", fresh["reason"])
def test_absent_lock(self):
fresh = issue_lock_store.assess_lock_freshness(None)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_ABSENT)
self.assertFalse(fresh["stale"])
class TestLegacyLocksStayProtected(_LockFixture):
"""AC-N8: deployment must not retroactively shorten an existing claim."""
def test_legacy_lock_with_a_stale_heartbeat_remains_live(self):
"""The deployment-safety case.
A four-hour legacy lease minted three hours ago has not heartbeated
once. Under the new grace it would be long gone; under its preserved
absolute expiry it is still live, and must stay that way.
"""
record = self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
expires_delta=timedelta(hours=1),
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
self.assertTrue(fresh["live"])
self.assertTrue(fresh["legacy_lease"])
self.assertTrue(fresh["legacy_expiry_preserved"])
def test_legacy_lock_past_its_absolute_expiry_is_expired_as_before(self):
record = self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=5),
heartbeat_delta=timedelta(hours=5),
expires_delta=timedelta(hours=-1),
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_EXPIRED)
def test_legacy_lock_is_never_reclaimed_by_the_heartbeat_band(self):
record = self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
expires_delta=timedelta(hours=1),
)
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
self.assertFalse(reclaim["reclaim_allowed"])
class TestReclaimAfterMissedHeartbeat(_LockFixture):
def test_missed_heartbeat_makes_ownership_reclaimable(self):
record = self.write_lock(
pid=os.getpid(),
heartbeat_delta=timedelta(minutes=15),
expires_delta=timedelta(hours=3),
)
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
self.assertTrue(reclaim["reclaim_allowed"])
self.assertIn("stale_missed_heartbeat", reclaim["reasons"][0])
def test_live_lease_is_never_reclaimable(self):
record = self.write_lock(heartbeat_delta=timedelta(minutes=1))
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
self.assertFalse(reclaim["reclaim_allowed"])
def test_dead_pid_reclaim_path_is_unchanged(self):
"""#753 must keep working through its original conditions."""
record = self.write_lock(pid=DEAD_PID, heartbeat_delta=timedelta(minutes=1))
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
self.assertTrue(reclaim["reclaim_allowed"])
self.assertTrue(reclaim["owner_pid_dead"])
class TestHeartbeatWriter(_LockFixture):
"""A4: flock + CAS + exact verification, and no revival path."""
def _heartbeat(self, **kwargs):
params = {
"remote": REMOTE,
"org": ORG,
"repo": REPO,
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": self.worktree,
"identity": IDENTITY,
"profile": PROFILE,
"task_session_id": "author_issue_work-aaaabbbbccccdddd",
"lock_dir": self.lock_dir.name,
"now": self.now,
}
params.update(kwargs)
return issue_lock_store.heartbeat_session_lock(**params)
def test_heartbeat_slides_expiry_and_advances_generation(self):
self.write_lock(heartbeat_delta=timedelta(minutes=4), generation=5)
result = self._heartbeat()
self.assertTrue(result["success"], result)
self.assertEqual(result["prior_generation"], 5)
self.assertEqual(result["lock_generation"], 6)
self.assertEqual(result["heartbeat_count"], 1)
self.assertEqual(result["last_heartbeat_at"], _ts(self.now))
self.assertEqual(result["expires_at"], _ts(self.now + timedelta(minutes=10)))
self.assertTrue(result["freshness"]["live"])
def test_heartbeat_is_durable_and_repeatable(self):
self.write_lock(heartbeat_delta=timedelta(minutes=4))
self._heartbeat()
second = self._heartbeat(now=self.now + timedelta(minutes=1))
self.assertTrue(second["success"], second)
self.assertEqual(second["heartbeat_count"], 2)
written = issue_lock_store.read_lock_file(self._path())
self.assertEqual(written["work_lease"]["heartbeat_count"], 2)
def test_stale_generation_is_refused(self):
self.write_lock(generation=5)
result = self._heartbeat(expected_generation=4)
self.assertFalse(result["success"])
self.assertIn("generation changed", result["reasons"][0])
def test_foreign_session_is_refused(self):
self.write_lock()
result = self._heartbeat(task_session_id="author_issue_work-ffffffffffffffff")
self.assertFalse(result["success"])
self.assertIn("task_session_id does not match", " ".join(result["reasons"]))
def test_missing_session_id_is_refused(self):
self.write_lock()
result = self._heartbeat(task_session_id="")
self.assertFalse(result["success"])
def test_foreign_claimant_is_refused(self):
self.write_lock()
for field, value in (
("identity", "someone-else"),
("profile", "other-profile"),
):
with self.subTest(field=field):
result = self._heartbeat(**{field: value})
self.assertFalse(result["success"])
def test_branch_and_worktree_mismatch_are_refused(self):
self.write_lock()
wrong_branch = self._heartbeat(branch_name=f"fix/issue-{ISSUE}-other")
self.assertFalse(wrong_branch["success"])
wrong_worktree = self._heartbeat(worktree_path="/tmp/not-the-worktree")
self.assertFalse(wrong_worktree["success"])
def test_lapsed_lease_cannot_be_heartbeated_back_to_life(self):
"""No revival path (A4).
A session that stopped proving liveness must reclaim under a fresh
generation, not restore ownership retroactively.
"""
self.write_lock(
heartbeat_delta=timedelta(minutes=30), expires_delta=timedelta(hours=1)
)
result = self._heartbeat()
self.assertFalse(result["success"])
self.assertIn("reclaimed", " ".join(result["reasons"]))
def test_absent_lock_cannot_be_created_by_heartbeat(self):
result = self._heartbeat()
self.assertFalse(result["success"])
self.assertIn("no durable lock", result["reasons"][0])
def test_legacy_lock_is_refused_until_rebound(self):
self.write_lock(lifecycle=None)
result = self._heartbeat()
self.assertFalse(result["success"])
self.assertTrue(result["legacy_lease"])
self.assertIn("rebound", " ".join(result["reasons"]))
class TestLegacyRebind(_LockFixture):
"""AC-N8 exit route: canonical exact-owner rebinding."""
def _rebind(self, **kwargs):
params = {
"remote": REMOTE,
"org": ORG,
"repo": REPO,
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": self.worktree,
"identity": IDENTITY,
"profile": PROFILE,
"lock_dir": self.lock_dir.name,
"now": self.now,
}
params.update(kwargs)
return issue_lock_store.rebind_legacy_lock(**params)
def test_rebind_mints_a_session_and_a_genuine_first_heartbeat(self):
self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
expires_delta=timedelta(hours=1),
generation=2,
)
result = self._rebind()
self.assertTrue(result["success"], result)
self.assertTrue(result["task_session_id"])
self.assertEqual(result["lock_generation"], 3)
written = issue_lock_store.read_lock_file(self._path())
lease = written["work_lease"]
self.assertEqual(
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
)
self.assertEqual(lease["last_heartbeat_at"], _ts(self.now))
self.assertEqual(lease["expires_at"], _ts(self.now + timedelta(minutes=10)))
self.assertFalse(issue_lock_store.is_legacy_lease(written))
# The original claim is preserved for audit rather than overwritten.
origin = written["legacy_rebind"]["legacy_origin"]
self.assertTrue(origin["created_at"])
self.assertEqual(origin["lifecycle"], lease_policy.LIFECYCLE_LEGACY)
def test_rebound_lock_can_then_heartbeat(self):
self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
expires_delta=timedelta(hours=1),
)
rebound = self._rebind()
beat = issue_lock_store.heartbeat_session_lock(
remote=REMOTE,
org=ORG,
repo=REPO,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.worktree,
identity=IDENTITY,
profile=PROFILE,
task_session_id=rebound["task_session_id"],
lock_dir=self.lock_dir.name,
now=self.now + timedelta(minutes=1),
)
self.assertTrue(beat["success"], beat)
def test_rebind_refuses_a_foreign_owner(self):
self.write_lock(lifecycle=None, expires_delta=timedelta(hours=1))
result = self._rebind(identity="someone-else")
self.assertFalse(result["success"])
def test_rebind_refuses_a_lock_already_on_the_lifecycle(self):
self.write_lock()
result = self._rebind()
self.assertFalse(result["success"])
self.assertFalse(result["legacy_lease"])
def test_rebind_is_not_a_recovery_path_for_a_lapsed_legacy_lease(self):
"""An expired legacy lease belongs to #760 renewal or #601 reclaim."""
self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=5),
heartbeat_delta=timedelta(hours=5),
expires_delta=timedelta(hours=-1),
)
result = self._rebind()
self.assertFalse(result["success"])
self.assertIn("not a recovery path", " ".join(result["reasons"]))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,650 @@
"""Publication of an unpublished local commit (#812 AC20).
Entry point B of #812: a registered worktree, clean, on its issue branch,
holding a local commit that has never been published. Exact-owner lease renewal
refuses such a claim for want of an observable remote head, and every existing
publication path is lock-derived, so the two predicates close a cycle around
work that is otherwise complete.
These tests exercise the disposition through its *evidence*, never through any
particular issue number: every case uses an arbitrary issue number against a
synthetic repository, and the same assertions hold for any other. Nothing here
reads, writes, or references the live protected worktree named in #812 AC17 —
that content is preserved evidence for the duration of this work, so the
fixtures below build their own repositories from scratch.
The remote is a local bare repository, so publication and read-after-write
verification are genuinely executed rather than mocked.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import branch_publish # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_renewal # noqa: E402
import issue_lock_store # noqa: E402
import mcp_server # noqa: E402
from mutation_profile_fixture import shared_mutation_env # noqa: E402
ISSUE = 9812
BRANCH = f"feat/issue-{ISSUE}-publish-fixture"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
GIT_REMOTE = "prgs"
def _ts(hours: int) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
class _PublishBase(unittest.TestCase):
"""Real git repo + real bare remote + durable lock naming the caller.
The recorded owner pid is deliberately **this live process**. That mirrors
the production shape #812 documents, where the pid belongs to a long-running
MCP daemon rather than to a dead author client, and it proves publication
never depends on a dead process (#812 AC24).
"""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.origin = tempfile.mkdtemp(prefix="issue812-origin-")
self.repo = tempfile.mkdtemp(prefix="issue812-work-")
for path in (self.origin, self.repo):
self.addCleanup(
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
)
self._init_repos()
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
# ── fixture construction ─────────────────────────────────────────────
def _git(self, *args, cwd=None):
return subprocess.run(
["git", "-C", cwd or self.repo, *args],
capture_output=True,
text=True,
check=True,
)
def _init_repos(self):
subprocess.run(
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
)
self._git("init", "-q", "-b", "master")
self._git("config", "user.email", "[email protected]")
self._git("config", "user.name", "Test")
self._git("remote", "add", GIT_REMOTE, self.origin)
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
fh.write("seed\n")
self._git("add", "seed.txt")
self._git("commit", "-q", "-m", "seed")
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
self._git("push", "-q", GIT_REMOTE, "master")
self._git("checkout", "-q", "-b", BRANCH)
with open(os.path.join(self.repo, "work.txt"), "w") as fh:
fh.write("unpublished implementation\n")
self._git("add", "work.txt")
self._git("commit", "-q", "-m", "unpublished implementation")
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
self.worktree = os.path.realpath(self.repo)
def lock_path(self):
return issue_lock_store.lock_file_path(
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def write_lock(self, **overrides):
path = self.lock_path()
claimant = overrides.pop(
"claimant", {"username": IDENTITY, "profile": PROFILE}
)
pid = overrides.pop("session_pid", os.getpid())
lease = {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": overrides.get("branch_name", BRANCH),
"worktree_path": overrides.get("worktree_path", self.worktree),
"claimant": claimant,
"created_at": _ts(-2),
"last_heartbeat_at": _ts(-2),
# Expired: entry point B's lease has lapsed, which is precisely why
# renewal — and therefore a published head — is needed.
"expires_at": _ts(-1),
}
lease.update(overrides.pop("work_lease", {}))
data = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": pid,
"pid": pid,
"lock_generation": 1,
"work_lease": lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue", claimant=claimant
),
}
data.update(overrides)
data["lock_file_path"] = path
issue_lock_store.save_lock_file(path, data)
return path
def _tool_env(self):
env = shared_mutation_env(
PROFILE, include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
# These tests repoint PROJECT_ROOT at a synthetic repository so the
# registered-worktree proof runs for real. Pin the parity gate to the
# server's own startup head so that repointing does not read as a stale
# daemon; the gate itself stays live and enforced.
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
return env
# ── tool driver ──────────────────────────────────────────────────────
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
"""Drive the public publication tool against the synthetic fixture."""
env = self._tool_env()
with patch(
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
), patch(
"mcp_server._auth", return_value="token x"
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": IDENTITY, "profile": PROFILE},
), patch.object(
mcp_server, "PROJECT_ROOT", self.repo
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_publish_unpublished_issue_branch(
issue_number=kwargs.pop("issue_number", ISSUE),
branch_name=kwargs.pop("branch_name", BRANCH),
worktree_path=kwargs.pop("worktree_path", self.worktree),
expected_head=expected_head or self.head_sha,
remote="prgs",
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
**kwargs,
)
def remote_head(self, branch=BRANCH):
res = subprocess.run(
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
capture_output=True, text=True, check=False,
)
return (res.stdout or "").strip() or None
class TestSuccessfulPublication(_PublishBase):
"""AC20 — the branch becomes observable and is verified after the write."""
def test_publishes_clean_unpublished_commit(self):
self.write_lock()
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["performed"])
self.assertTrue(result["published"])
self.assertTrue(result["verified"], "read-after-write must be proven")
self.assertEqual(result["remote_head_sha"], self.head_sha)
self.assertEqual(self.remote_head(), self.head_sha)
def test_publication_does_not_rewrite_the_commit(self):
self.write_lock()
self.run_publish()
# The published object is the same commit, not a copy or a rewrite.
self.assertEqual(self.remote_head(), self.head_sha)
self.assertEqual(
self._git("rev-parse", "HEAD").stdout.strip(), self.head_sha
)
def test_exact_next_action_names_the_lock_call(self):
self.write_lock()
result = self.run_publish()
self.assertIn("gitea_lock_issue", result["exact_next_action"])
class TestFailsClosed(_PublishBase):
"""AC20/AC9 — each refusal reason, exercised independently."""
def test_changed_local_head_refuses(self):
self.write_lock()
stale = self.base_sha # a real commit, but not the declared head
result = self.run_publish(expected_head=stale)
self.assertFalse(result["success"])
self.assertTrue(
any("local commit changed" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head(), "refusal must not publish")
def test_abbreviated_sha_refuses(self):
self.write_lock()
result = self.run_publish(expected_head=self.head_sha[:8])
self.assertFalse(result["success"])
self.assertTrue(
any("40-character" in r for r in result["reasons"]), result["reasons"]
)
def test_dirty_tracked_worktree_refuses(self):
self.write_lock()
with open(os.path.join(self.repo, "work.txt"), "a") as fh:
fh.write("uncommitted edit\n")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("dirty tracked files" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIn("work.txt", result["evidence"]["dirty_tracked_files"])
self.assertIsNone(self.remote_head())
def test_untracked_file_refuses(self):
self.write_lock()
with open(os.path.join(self.repo, "stray.txt"), "w") as fh:
fh.write("not committed\n")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("untracked files" in r for r in result["reasons"]), result["reasons"]
)
self.assertIn("stray.txt", result["evidence"]["untracked_files"])
self.assertIsNone(self.remote_head())
def test_unexpected_remote_head_refuses(self):
"""A remote head that is not an ancestor must never be overwritten."""
self.write_lock()
# Publish a divergent commit to the branch from a separate line.
self._git("checkout", "-q", "-b", "divergent", self.base_sha)
with open(os.path.join(self.repo, "other.txt"), "w") as fh:
fh.write("someone else's work\n")
self._git("add", "other.txt")
self._git("commit", "-q", "-m", "divergent")
divergent = self._git("rev-parse", "HEAD").stdout.strip()
self._git("push", "-q", GIT_REMOTE, f"{divergent}:refs/heads/{BRANCH}")
self._git("checkout", "-q", BRANCH)
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("not an ancestor" in r for r in result["reasons"]), result["reasons"]
)
self.assertEqual(
self.remote_head(), divergent, "the other head must survive intact"
)
def test_fast_forward_remote_head_is_allowed(self):
"""An ancestor head is an honest fast-forward, not a conflict."""
self.write_lock()
self._git("push", "-q", GIT_REMOTE, f"{self.base_sha}:refs/heads/{BRANCH}")
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["evidence"]["fast_forward_from_remote"])
self.assertEqual(self.remote_head(), self.head_sha)
def test_content_hash_mismatch_refuses(self):
self.write_lock()
wrong = {"work.txt": "0" * 64}
result = self.run_publish(expected_file_hashes=wrong)
self.assertFalse(result["success"])
self.assertTrue(
any("declared content hashes" in r for r in result["reasons"]),
result["reasons"],
)
self.assertFalse(result["evidence"]["file_hashes_verified"])
self.assertIsNone(self.remote_head())
def test_matching_content_hashes_publish(self):
self.write_lock()
digests = branch_publish.hash_worktree_files(self.worktree, ["work.txt"])
result = self.run_publish(expected_file_hashes=digests)
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["evidence"]["file_hashes_verified"])
def test_missing_declared_file_refuses(self):
self.write_lock()
result = self.run_publish(expected_file_hashes={"absent.txt": "0" * 64})
self.assertFalse(result["success"])
self.assertTrue(
any("missing or unreadable" in r for r in result["reasons"]),
result["reasons"],
)
def test_foreign_claimant_refuses(self):
"""Ownership comes from the durable record, not from the caller."""
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("foreign claim" in r for r in result["reasons"]), result["reasons"]
)
self.assertIsNone(self.remote_head())
def test_foreign_profile_refuses(self):
self.write_lock(
claimant={"username": IDENTITY, "profile": "test-reviewer-prgs"}
)
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("claimant profile" in r for r in result["reasons"]), result["reasons"]
)
def test_absent_lock_record_refuses(self):
"""No recorded claim means this cannot be used to bypass the lock."""
result = self.run_publish() # no write_lock()
self.assertFalse(result["success"])
self.assertTrue(
any("no durable issue-lock record" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head())
def test_branch_mismatch_against_lock_refuses(self):
self.write_lock(branch_name=f"feat/issue-{ISSUE}-different")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("records branch" in r for r in result["reasons"]), result["reasons"]
)
def test_worktree_mismatch_against_lock_refuses(self):
self.write_lock(worktree_path="/tmp/some/other/worktree")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("records worktree" in r for r in result["reasons"]), result["reasons"]
)
def test_competing_open_pr_on_another_branch_refuses(self):
self.write_lock()
competing = [{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
result = self.run_publish(open_prs=competing)
self.assertFalse(result["success"])
self.assertTrue(
any("already claim issue" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head())
def test_open_pr_on_the_same_branch_is_not_competing(self):
"""This branch's own PR is not a rival claim against itself."""
self.write_lock()
own = [{"number": 77, "head": {"ref": BRANCH}}]
result = self.run_publish(open_prs=own)
self.assertTrue(result["success"], result.get("reasons"))
class TestGuardStrictnessPreserved(_PublishBase):
"""AC15 — publication is an operation, never a weakening of the guards."""
def test_non_issue_branch_refuses(self):
self._git("checkout", "-q", "-b", "scratch/not-issue-linked")
self.write_lock(branch_name="scratch/not-issue-linked")
result = self.run_publish(branch_name="scratch/not-issue-linked")
self.assertFalse(result["success"])
self.assertTrue(
any("issue-linked" in r for r in result["reasons"]), result["reasons"]
)
def test_stable_branch_refuses(self):
self.write_lock(branch_name="master")
result = self.run_publish(branch_name="master")
self.assertFalse(result["success"])
self.assertTrue(
any("issue-linked" in r or "stable branch" in r for r in result["reasons"]),
result["reasons"],
)
def test_branch_number_must_match_the_issue(self):
other = "feat/issue-7777-mismatched"
self._git("checkout", "-q", "-b", other)
self.write_lock(branch_name=other)
result = self.run_publish(branch_name=other)
self.assertFalse(result["success"])
self.assertTrue(
any("does not carry issue number" in r for r in result["reasons"]),
result["reasons"],
)
def test_unregistered_worktree_refuses(self):
"""#713 — an improvised directory is not a registered worktree."""
path = self.write_lock()
assessment = branch_publish.assess_unpublished_commit_publication(
issue_lock_store.read_lock_file(path),
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
expected_head=self.head_sha, remote="prgs", org=ORG, repo=REPO,
identity=IDENTITY, profile=PROFILE,
worktree_state={
"current_branch": BRANCH, "porcelain_status": "",
"head_sha": self.head_sha,
},
worktree_registered=False,
remote_probe={"probe_ok": True, "remote_branch_exists": False},
)
self.assertEqual(assessment["outcome"], branch_publish.REFUSED)
self.assertTrue(
any("not listed in git worktree list" in r
for r in assessment["reasons"]),
assessment["reasons"],
)
def test_unobservable_remote_refuses(self):
"""An unknown remote state must not be mistaken for an absent branch."""
self.write_lock()
result = self.run_publish(git_remote_name="no-such-remote")
self.assertFalse(result["success"])
self.assertTrue(
any("could not be observed" in r for r in result["reasons"]),
result["reasons"],
)
class TestRecordSeparation(_PublishBase):
"""AC23 — the durable issue lock and the workflow lease are distinct."""
def test_publication_leaves_the_issue_lock_byte_identical(self):
path = self.write_lock()
with open(path, "rb") as fh:
before = fh.read()
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
with open(path, "rb") as fh:
after = fh.read()
self.assertEqual(before, after, "publication must not mutate the lock record")
self.assertFalse(result["issue_lock_record_mutated"])
self.assertFalse(result["workflow_lease_touched"])
def test_refusal_also_reports_untouched_records(self):
result = self.run_publish() # refuses: no lock record
self.assertFalse(result["issue_lock_record_mutated"])
self.assertFalse(result["workflow_lease_touched"])
def test_lock_generation_is_not_advanced(self):
path = self.write_lock()
self.run_publish()
lock = issue_lock_store.read_lock_file(path)
self.assertEqual(lock["lock_generation"], 1)
class TestTruthfulProcessEvidence(_PublishBase):
"""AC24 — a live daemon pid is never represented as a dead process."""
def test_live_recorded_pid_does_not_block_publication(self):
# The recorded pid is this live process, standing in for the live MCP
# daemon. Reclaim would refuse here; publication legitimately does not.
path = self.write_lock(session_pid=os.getpid())
lock = issue_lock_store.read_lock_file(path)
self.assertEqual(lock["pid"], os.getpid())
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertEqual(self.remote_head(), self.head_sha)
def test_liveness_is_not_consulted_as_evidence(self):
self.write_lock(session_pid=os.getpid())
result = self.run_publish()
self.assertFalse(result["evidence"]["owner_pid_liveness_consulted"])
def test_reclaim_still_refuses_for_the_same_live_pid(self):
"""Publication does not soften the reclaim predicate it routes around."""
path = self.write_lock(session_pid=os.getpid())
lock = issue_lock_store.read_lock_file(path)
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
self.assertFalse(reclaim["reclaim_allowed"])
class TestIdempotentRetry(_PublishBase):
"""AC20 — retry is safe and read-after-write is proven every time."""
def test_second_publication_reports_already_published(self):
self.write_lock()
first = self.run_publish()
self.assertTrue(first["performed"])
second = self.run_publish()
self.assertTrue(second["success"], second.get("reasons"))
self.assertFalse(second["performed"], "no second push is needed")
self.assertTrue(second["published"])
self.assertTrue(second["verified"])
self.assertEqual(second["outcome"], branch_publish.ALREADY_PUBLISHED)
self.assertEqual(self.remote_head(), self.head_sha)
class TestDryRun(_PublishBase):
"""AC12 — dry run reports the decision and mutates nothing."""
def test_dry_run_reports_intent_without_publishing(self):
self.write_lock()
result = self.run_publish(dry_run=True)
self.assertTrue(result["success"])
self.assertTrue(result["dry_run"])
self.assertTrue(result["would_publish"])
self.assertFalse(result["performed"])
self.assertIsNone(self.remote_head(), "dry run must not publish")
def test_dry_run_and_apply_agree_on_a_refusal(self):
"""AC11 — the reported decision does not depend on which mode ran."""
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
dry = self.run_publish(dry_run=True)
applied = self.run_publish()
self.assertFalse(dry["success"])
self.assertFalse(applied["success"])
self.assertEqual(dry["reasons"], applied["reasons"])
class TestRenewalUnblocked(_PublishBase):
"""AC20/AC21 — renewal is permitted only after verified publication."""
def _renewal(self, remote_head):
return issue_lock_renewal.assess_exact_owner_lease_renewal(
issue_lock_store.read_lock_file(self.lock_path()),
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
remote="prgs", org=ORG, repo=REPO,
identity=IDENTITY, profile=PROFILE,
current_branch=BRANCH, porcelain_status="", worktree_exists=True,
head_sha=self.head_sha, remote_head_sha=remote_head,
)
def test_renewal_refuses_before_publication(self):
self.write_lock()
decision = self._renewal(None)
self.assertFalse(decision["renewal_sanctioned"])
self.assertTrue(
any("unpublished branch" in r for r in decision["reasons"]),
decision["reasons"],
)
def test_renewal_is_sanctioned_after_publication(self):
self.write_lock()
result = self.run_publish()
self.assertTrue(result["verified"], result.get("reasons"))
decision = self._renewal(self.remote_head())
self.assertTrue(decision["renewal_sanctioned"], decision["reasons"])
class TestProtectedAssetUntouched(unittest.TestCase):
"""AC17 — no test or fixture may reference the protected worktree."""
def test_no_reference_to_the_protected_worktree(self):
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.dirname(here)
needle = "issue-635-project-registry" + "-api"
for path in (
os.path.join(here, "test_issue_812_publish_unpublished_commit.py"),
os.path.join(root, "branch_publish.py"),
):
with open(path, "r", encoding="utf-8") as fh:
body = fh.read()
self.assertNotIn(needle, body)
if __name__ == "__main__": # pragma: no cover
unittest.main()
@@ -0,0 +1,622 @@
"""Publication preflight must receive the caller's worktree (#815).
``gitea_publish_unpublished_issue_branch`` takes a **required** ``worktree_path``
but resolved it only *after* ``verify_preflight_purity`` had already run. Every
workspace-resolution layer behind that preflight canonical root, root checkout,
create-issue bootstrap, the #618 branches-only guard, issue scope, and anti-stomp
therefore received ``None`` and fell back to the MCP process root. A daemon
rooted at the stable control checkout refused a valid registered issue worktree
that the caller had explicitly supplied, before the publication assessor ever ran.
The #812 suite could not see this. Its fixture sets ``self.worktree =
os.path.realpath(self.repo)`` and patches ``PROJECT_ROOT`` to that same path, so
the fallback resolved to the very worktree the argument named. The production
topology control checkout on a stable branch, issue worktree somewhere else
was never constructed, and preflight additionally no-ops under pytest unless
production guards are forced on.
These tests build that topology honestly:
* ``PROJECT_ROOT`` is a control checkout sitting on ``master``;
* the registered issue worktree is a genuinely separate path under ``branches/``;
* ``GITEA_TEST_FORCE_PRODUCTION_GUARDS`` is set so the #618 guard really runs;
* no patch makes the issue worktree appear to be ``PROJECT_ROOT``.
Nothing here reads, writes, or references the protected worktree named in #812
AC17 and #815 AC9. Every fixture is built from scratch against a local bare
remote, so publication and read-after-write verification genuinely execute.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import mcp_server # noqa: E402
from mutation_profile_fixture import shared_mutation_env # noqa: E402
ISSUE = 9815
BRANCH = f"feat/issue-{ISSUE}-forwarding-fixture"
WORKTREE_DIRNAME = BRANCH.replace("/", "-")
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
GIT_REMOTE = "prgs"
AUTHOR_PROFILE = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
"gitea.pr.create", "gitea.repo.commit", "gitea.branch.push",
],
"forbidden_operations": [],
"audit_label": "prgs-author",
}
def _ts(hours: int) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
class TestPreflightReceivesTheWorktree(unittest.TestCase):
"""AC1 — the supplied path reaches ``verify_preflight_purity`` itself.
Follows the #735 capture pattern: replace preflight with a recorder that
raises, so the argument can be proven forwarded without performing the
mutation. This is the direct unit-level statement of the defect.
"""
def _capture_preflight(self, **kwargs):
captured: dict = {}
def _capture(*a, **kw):
captured.update(kw)
captured["_args"] = a
raise RuntimeError("capture-only")
with patch.object(
mcp_server, "verify_preflight_purity", side_effect=_capture
), patch.object(
mcp_server, "get_profile", return_value=AUTHOR_PROFILE
), patch.object(
mcp_server, "_resolve",
return_value=("gitea.prgs.cc", ORG, REPO),
), patch.object(
mcp_server, "_auth", return_value="token fake",
), patch.object(
mcp_server.role_session_router,
"check_author_mutation_after_reviewer_stop",
return_value=(True, []),
), patch.object(
mcp_server, "_namespace_mutation_block", return_value=None
), patch.object(
mcp_server, "_profile_permission_block", return_value=None
):
try:
mcp_server.gitea_publish_unpublished_issue_branch(**kwargs)
except RuntimeError as exc:
if "capture-only" not in str(exc) and not captured:
raise
self.assertTrue(
captured,
"gitea_publish_unpublished_issue_branch never called "
"verify_preflight_purity",
)
return captured
def _base_kwargs(self, **overrides):
kwargs = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": "/tmp/issue-815-explicit-worktree",
"expected_head": "a" * 40,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"git_remote_name": GIT_REMOTE,
}
kwargs.update(overrides)
return kwargs
def test_explicit_worktree_path_reaches_preflight(self):
captured = self._capture_preflight(**self._base_kwargs())
self.assertEqual(
captured.get("worktree_path"),
os.path.realpath(os.path.abspath("/tmp/issue-815-explicit-worktree")),
"the authoritative worktree_path must be forwarded into preflight",
)
def test_forwarded_path_is_the_one_publication_uses(self):
"""AC4 — preflight and publication must judge the same resolved path."""
raw = "/tmp/issue-815-explicit-worktree/./"
captured = self._capture_preflight(**self._base_kwargs(worktree_path=raw))
expected = os.path.realpath(os.path.abspath(raw.strip()))
self.assertEqual(captured.get("worktree_path"), expected)
def test_blank_worktree_path_forwards_none(self):
"""AC5/AC8 — nothing usable supplied keeps the fail-closed fallback."""
for blank in ("", " "):
with self.subTest(blank=repr(blank)):
captured = self._capture_preflight(
**self._base_kwargs(worktree_path=blank)
)
self.assertIsNone(
captured.get("worktree_path"),
"a blank worktree must not resolve to the process cwd",
)
def test_org_repo_and_task_forwarding_are_not_regressed(self):
"""AC6 — #735's org/repo forwarding and the task name still hold."""
captured = self._capture_preflight(**self._base_kwargs())
self.assertEqual(captured.get("org"), ORG)
self.assertEqual(captured.get("repo"), REPO)
self.assertEqual(captured.get("task"), "publish_unpublished_branch")
class _ProductionTopologyBase(unittest.TestCase):
"""Control checkout on master + a distinct registered issue worktree.
This is the shape the production daemon runs in and the shape the #812
fixture never built. ``PROJECT_ROOT`` is the control checkout; the issue
worktree is a real registered worktree at a different path; production
guards are forced on so the #618 branches-only guard genuinely evaluates.
"""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.origin = tempfile.mkdtemp(prefix="issue815-origin-")
self.control = tempfile.mkdtemp(prefix="issue815-control-")
for path in (self.origin, self.control):
self.addCleanup(
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
)
self._init_repos()
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
def _git(self, *args, cwd=None):
return subprocess.run(
["git", "-C", cwd or self.control, *args],
capture_output=True, text=True, check=True,
)
def _init_repos(self):
subprocess.run(
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
)
self._git("init", "-q", "-b", "master")
self._git("config", "user.email", "[email protected]")
self._git("config", "user.name", "Test")
self._git("remote", "add", GIT_REMOTE, self.origin)
with open(os.path.join(self.control, "seed.txt"), "w") as fh:
fh.write("seed\n")
# The real repository gitignores branches/, so a registered worktree
# living there does not dirty the stable control checkout. Mirror that,
# or the #615 dirty-runtime block fires on the worktree we just created.
with open(os.path.join(self.control, ".gitignore"), "w") as fh:
fh.write("branches/\n")
self._git("add", "seed.txt", ".gitignore")
self._git("commit", "-q", "-m", "seed")
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
self._git("push", "-q", GIT_REMOTE, "master")
# The control checkout STAYS on master. This is the whole point: the
# daemon's process root is the stable control checkout, never the
# worktree the publication targets.
self.worktree = os.path.realpath(
os.path.join(self.control, "branches", WORKTREE_DIRNAME)
)
self._git("worktree", "add", "-q", "-b", BRANCH, self.worktree, "master")
with open(os.path.join(self.worktree, "work.txt"), "w") as fh:
fh.write("unpublished implementation\n")
self._git("add", "work.txt", cwd=self.worktree)
self._git("commit", "-q", "-m", "unpublished implementation", cwd=self.worktree)
self.head_sha = self._git("rev-parse", "HEAD", cwd=self.worktree).stdout.strip()
self.control_branch = self._git(
"rev-parse", "--abbrev-ref", "HEAD"
).stdout.strip()
# ── durable lock naming the caller and the issue worktree ────────────
def lock_path(self):
return issue_lock_store.lock_file_path(
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def write_lock(self, *, bind_session=True, **overrides):
path = self.lock_path()
claimant = overrides.pop(
"claimant", {"username": IDENTITY, "profile": PROFILE}
)
pid = overrides.pop("session_pid", os.getpid())
lease = {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": overrides.get("branch_name", BRANCH),
"worktree_path": overrides.get("worktree_path", self.worktree),
"claimant": claimant,
"created_at": _ts(-2),
"last_heartbeat_at": _ts(-2),
"expires_at": _ts(-1),
}
lease.update(overrides.pop("work_lease", {}))
data = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": pid,
"pid": pid,
"lock_generation": 1,
"work_lease": lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue", claimant=claimant
),
}
data.update(overrides)
data["lock_file_path"] = path
issue_lock_store.save_lock_file(path, data)
# Bind the session pointer so the #683 issue-scope guard resolves an
# owning issue for this author session. In real production the publish
# task does not require a session lock — require_author_lock is keyed on
# the test-only production_guards_forced() flag, which this suite must
# set to make preflight run at all — so this pointer is fixture
# scaffolding to clear a guard production would not apply here, never a
# softening of the worktree-forwarding behaviour under test. The
# preflight-negative cases below leave it unbound precisely so the #618
# guard is reached with no session fallback to rescue a bad worktree.
if bind_session:
pointer = {
"pid": os.getpid(),
"lock_file_path": path,
"issue_number": ISSUE,
"branch_name": data["branch_name"],
"remote": "prgs",
"org": ORG,
"repo": REPO,
}
issue_lock_store.save_lock_file(
issue_lock_store.session_pointer_path(self.lock_dir.name), pointer
)
return path
def _tool_env(self):
env = shared_mutation_env(
PROFILE, include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
# The defect only exists where preflight actually runs. Under pytest the
# production root/branches/scope guards are skipped unless forced on, so
# force them: this test exists to exercise the #618 guard, not to bypass
# it. Parity is pinned to the server's own startup head so repointing
# PROJECT_ROOT does not read as a stale daemon.
env["GITEA_TEST_FORCE_PRODUCTION_GUARDS"] = "1"
# Production is a promoted stable-control runtime. The pytest process
# itself runs from a branches/ worktree, which the #615 runtime-mode
# gate correctly classifies as dev-test; declaring the sanctioned mode
# models the production daemon rather than defeating the gate. Without
# this, forcing production guards on would trip the *runtime-mode* block
# for a reason unrelated to the #815 worktree-forwarding defect.
env["GITEA_MCP_RUNTIME_MODE"] = "stable-control"
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
return env
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
"""Drive the public tool with PROJECT_ROOT pinned to the CONTROL checkout."""
env = self._tool_env()
with patch(
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
), patch(
"mcp_server._auth", return_value="token x"
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": IDENTITY, "profile": PROFILE},
), patch.object(
# NOTE: the control checkout — deliberately NOT self.worktree.
mcp_server, "PROJECT_ROOT", self.control
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_publish_unpublished_issue_branch(
issue_number=kwargs.pop("issue_number", ISSUE),
branch_name=kwargs.pop("branch_name", BRANCH),
worktree_path=kwargs.pop("worktree_path", self.worktree),
expected_head=expected_head or self.head_sha,
remote="prgs",
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
**kwargs,
)
def remote_head(self, branch=BRANCH):
res = subprocess.run(
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
capture_output=True, text=True, check=False,
)
return (res.stdout or "").strip() or None
class TestForwardingClearsThe618Guard(_ProductionTopologyBase):
"""AC2 — the faithful production reproduction, and the sharpest fix proof.
The production recovery worker had **no** session issue lock acquiring one
was the very thing the deadlock prevented so preflight had nothing but the
explicit ``worktree_path`` argument to resolve the workspace from. This class
reproduces exactly that: no session pointer is bound, so there is no
author-lock fallback to rescue a dropped argument.
With the argument forwarded (fixed source) the #618 branches-only guard
accepts the registered issue worktree and the call advances to the next
guard. With the argument dropped (the buggy source this issue reports)
preflight falls back to ``PROJECT_ROOT`` the stable control checkout and
the #618 guard traps the call there. The two outcomes are told apart by the
guard that fired, on its own error text.
This test therefore *fails* against the unpatched source (the call is trapped
at #618 instead of clearing it), which is what makes it a regression rather
than a smoke test.
"""
_CONTROL_CHECKOUT_MARKERS = ("stable control checkout", "#618")
def test_explicit_worktree_clears_618_without_a_session_lock(self):
# No write_lock(): the session is deliberately unbound, as in production.
with self.assertRaises(RuntimeError) as ctx:
self.run_publish()
message = str(ctx.exception)
# The workspace guard is satisfied — the failure is the *later* scope
# guard (no owning issue), never the control-checkout refusal. If the
# argument were dropped, this call would be trapped at #618 instead.
for marker in self._CONTROL_CHECKOUT_MARKERS:
self.assertNotIn(
marker, message,
f"the explicit worktree must clear #618; got a control-checkout "
f"refusal instead: {message}",
)
self.assertIn(
"owning issue", message,
f"expected the downstream scope guard to fire, got: {message}",
)
self.assertIsNone(self.remote_head())
def test_dropped_argument_would_be_trapped_at_618(self):
# Simulate the buggy call shape directly: no session lock, and preflight
# given no worktree, exactly as the unpatched source left it. This pins
# the control-checkout refusal that the fix eliminates, so the pair of
# tests brackets the defect from both sides regardless of which source
# version is loaded.
env = self._tool_env()
with patch(
"mcp_server._list_open_pulls", return_value=[]
), patch(
"mcp_server._auth", return_value="token x"
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": IDENTITY, "profile": PROFILE},
), patch.object(
mcp_server, "PROJECT_ROOT", self.control
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
with self.assertRaises(RuntimeError) as ctx:
# Drive verify_preflight_purity the way the buggy body did:
# no worktree_path forwarded at all.
mcp_server.verify_preflight_purity(
"prgs",
task="publish_unpublished_branch",
org=ORG,
repo=REPO,
)
message = str(ctx.exception)
self.assertTrue(
any(m in message for m in self._CONTROL_CHECKOUT_MARKERS),
f"a dropped worktree must trap at the control checkout: {message}",
)
self.assertIsNone(self.remote_head())
class TestProductionTopologyPublishes(_ProductionTopologyBase):
"""AC2/AC4/AC7 — the explicit registered worktree is what preflight validates."""
def test_fixture_is_genuinely_the_production_topology(self):
"""Guard the guard: if this drifts, the regression stops meaning anything."""
self.assertNotEqual(
os.path.realpath(self.control), self.worktree,
"the issue worktree must not be PROJECT_ROOT",
)
self.assertEqual(
self.control_branch, "master",
"the control checkout must sit on a stable branch",
)
self.assertTrue(
os.path.realpath(self.worktree).startswith(
os.path.realpath(os.path.join(self.control, "branches")) + os.sep
),
"the issue worktree must live under branches/",
)
listed = subprocess.run(
["git", "-C", self.control, "worktree", "list"],
capture_output=True, text=True, check=True,
).stdout
self.assertIn(
self.worktree, listed, "the issue worktree must be genuinely registered"
)
def test_publishes_from_a_control_rooted_daemon(self):
"""The exact production failure: this refused with #618 before the fix."""
self.write_lock()
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
res = self.run_publish()
self.assertTrue(res.get("success"), res)
self.assertTrue(res.get("performed"), res)
self.assertEqual(self.remote_head(), self.head_sha)
def test_dry_run_uses_the_explicit_worktree(self):
"""AC4 — dry-run reaches the same decision without publishing."""
self.write_lock()
res = self.run_publish(dry_run=True)
self.assertTrue(res.get("success"), res)
self.assertFalse(res.get("performed"), res)
self.assertTrue(res.get("would_publish"), res)
self.assertIsNone(self.remote_head(), "dry-run must not publish")
def test_dry_run_and_apply_agree_on_the_same_worktree(self):
"""AC4 — both paths resolve the same workspace, so both succeed."""
self.write_lock()
dry = self.run_publish(dry_run=True)
self.assertTrue(dry.get("would_publish"), dry)
applied = self.run_publish()
self.assertTrue(applied.get("performed"), applied)
self.assertEqual(self.remote_head(), self.head_sha)
def test_read_after_write_verification_still_runs(self):
"""AC6 — PR #814's post-publication verification is unchanged."""
self.write_lock()
res = self.run_publish()
self.assertTrue(res.get("verified"), res)
self.assertEqual(res.get("remote_head_sha"), self.head_sha)
class TestProductionTopologyFailsClosed(_ProductionTopologyBase):
"""AC3/AC5/AC8 — the fix does not weaken any refusal.
A refusal reaches the caller by one of two mechanisms, and this class holds
them apart deliberately. A bad *workspace* is caught by the #618 preflight
guard, which raises before the assessor is built. A bad *content/ownership*
fact passes preflight (the worktree itself is fine) and is then refused by
the publication assessor, which returns ``success: False``. Both are
fail-closed; asserting the wrong mechanism would hide a regression.
"""
# ── #618 preflight refusals: no session lock, so nothing rescues a bad
# workspace and the guard fires exactly as it does in production ──────
def _assert_preflight_raises(self, **kwargs):
with self.assertRaises(RuntimeError) as ctx:
self.run_publish(**kwargs)
self.assertIsNone(
self.remote_head(), "a blocked publication must not reach the remote"
)
return str(ctx.exception)
def test_blank_worktree_path_fails_closed_via_618(self):
"""AC5 — a blank path forwards None, so preflight sees the control root."""
for blank in ("", " "):
with self.subTest(blank=repr(blank)):
message = self._assert_preflight_raises(worktree_path=blank)
self.assertIn("618", message)
def test_control_checkout_as_worktree_fails_closed_via_618(self):
"""AC5 — naming the stable control checkout explicitly is still refused."""
message = self._assert_preflight_raises(worktree_path=self.control)
self.assertIn("618", message)
def test_unregistered_directory_fails_closed(self):
"""AC3 — a plain directory under branches/ is not a registered worktree."""
bogus = os.path.join(self.control, "branches", "not-a-worktree")
os.makedirs(bogus, exist_ok=True)
self._assert_preflight_raises(worktree_path=bogus)
def test_missing_worktree_path_fails_closed(self):
"""AC3 — a path that does not exist is refused, not silently replaced."""
missing = os.path.join(self.control, "branches", "absent-worktree")
self._assert_preflight_raises(worktree_path=missing)
# ── assessor refusals: preflight passes on a valid worktree, then the
# publication assessor refuses on content/ownership evidence ──────────
def _assert_assessor_refuses(self, **kwargs):
res = self.run_publish(**kwargs)
self.assertFalse(res.get("success"), res)
self.assertFalse(res.get("performed"), res)
self.assertIsNone(self.remote_head())
return res
def test_changed_local_head_still_refuses(self):
"""AC6 — the declared expected_head remains authoritative."""
self.write_lock()
self._assert_assessor_refuses(expected_head="b" * 40)
def test_foreign_claimant_still_refuses(self):
"""AC6 — ownership still comes from the durable lock record."""
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
self._assert_assessor_refuses()
def test_dirty_worktree_still_refuses(self):
"""AC6 — cleanliness enforcement survives the forwarding change."""
self.write_lock()
with open(os.path.join(self.worktree, "work.txt"), "a") as fh:
fh.write("uncommitted drift\n")
self._assert_assessor_refuses()
def test_competing_open_pr_still_refuses(self):
"""AC6 — a rival claim on another branch still blocks."""
self.write_lock()
self._assert_assessor_refuses(
open_prs=[{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
)
def test_issue_lock_record_is_not_mutated_by_a_refusal(self):
"""AC6 — record separation (#812 AC23) is unaffected by this change."""
path = self.write_lock()
with open(path, "rb") as fh:
before = fh.read()
self._assert_assessor_refuses(expected_head="c" * 40)
with open(path, "rb") as fh:
self.assertEqual(before, fh.read())
class TestProtectedFixtureNotReferenced(unittest.TestCase):
"""AC9 — this regression never names the protected #635 fixture.
The forbidden tokens are reconstructed from fragments so this assertion
file does not itself contain them and produce a false positive.
"""
def test_no_reference_to_the_protected_worktree(self):
forbidden = [
"issue-635-" + "project-registry-api",
"b2f6e9a6dc40e9651ef8" + "76f322dd0a68bddebfd8",
]
here = os.path.abspath(__file__)
with open(here, "r", encoding="utf-8") as fh:
text = fh.read()
for token in forbidden:
self.assertNotIn(
token, text,
f"the protected #635 fixture must not be referenced: {token}",
)
if __name__ == "__main__":
unittest.main()
+269
View File
@@ -78,6 +78,95 @@ class TestBlockReasonsAndReport(unittest.TestCase):
self.assertTrue(report["recovery"])
class TestLiveRemoteParity(unittest.TestCase):
"""#610: parity must account for the live remote master, not just local.
The daemon can be stale relative to the live remote target while the local
checkout HEAD still matches the daemon's startup commit, so local parity
reports green even though a mutation would run against outdated code.
"""
SHA_C = "c" * 40
def test_distinguishes_three_shas(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertEqual(res["daemon_start_head"], SHA_A)
self.assertEqual(res["local_head"], SHA_A)
self.assertEqual(res["live_remote_head"], SHA_B)
def test_mutation_safe_only_when_all_three_match(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
self.assertTrue(res["mutation_safe"])
self.assertTrue(res["live_known"])
self.assertFalse(res["live_stale"])
def test_live_stale_when_remote_advanced_past_daemon(self):
# Local checkout still matches the daemon start (local parity green),
# but the live remote master has advanced -> daemon is live-stale.
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertTrue(res["in_parity"]) # local parity still green
self.assertTrue(res["live_stale"])
self.assertFalse(res["mutation_safe"])
self.assertTrue(any("live" in r.lower() for r in res["reasons"]))
def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
# Non-goal: unfetchable live remote must not be treated as stale for
# read-only, but a mutation-safe claim fails closed.
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=None)
self.assertFalse(res["live_known"])
self.assertFalse(res["mutation_safe"])
self.assertFalse(res["live_stale"])
self.assertTrue(res["in_parity"])
def test_default_live_remote_preserves_legacy_shape(self):
# Callers that do not supply a live head keep the pre-#610 behavior:
# in-parity, not live-stale, no live-derived block.
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertFalse(res["live_stale"])
self.assertEqual(mp.parity_block_reasons(res), [])
class TestLiveStaleBlockAndReport(unittest.TestCase):
"""#610: live-staleness must block mutations and surface a typed blocker."""
def test_live_stale_produces_block_reasons(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertTrue(mp.parity_block_reasons(res))
def test_disable_env_suppresses_live_stale_block(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
self.assertEqual(mp.parity_block_reasons(res), [])
def test_resolver_disagreement_returns_typed_blocker(self):
# Parity says local-green, resolver says restart required -> disagreement
# is a typed, fail-closed blocker naming the resolver as authoritative.
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
self.assertIsNotNone(blocker)
self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
self.assertTrue(blocker["restart_required"])
self.assertTrue(blocker["resolver_authoritative"])
def test_no_disagreement_when_resolver_agrees(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertIsNone(
mp.parity_resolver_disagreement(res, resolver_restart_required=False))
def test_live_stale_report_names_live_remote(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
report = mp.parity_report(res)
self.assertEqual(report["live_remote_head"], SHA_B)
self.assertTrue(report["restart_required"])
class TestReadGitHead(unittest.TestCase):
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
@@ -95,6 +184,149 @@ class TestReadGitHead(unittest.TestCase):
self.assertIsNone(mp.read_git_head(""))
class TestReadRemoteMasterHead(unittest.TestCase):
"""#610: live remote master head reader (env-overridable, fails to None)."""
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)
def test_blank_override_is_none(self):
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: " "}):
self.assertIsNone(mp.read_remote_master_head("/nonexistent"))
def test_unfetchable_remote_is_none(self):
# No override; a bogus root/remote must fail closed to None, never raise.
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
with patch.dict(os.environ, env, clear=True):
self.assertIsNone(
mp.read_remote_master_head("/nonexistent", remote="nope"))
class TestRemoteHeadCache(unittest.TestCase):
"""#610: live remote reads are cached with a TTL to stay off the network.
The parity gate runs on every mutation and every runtime-context read, so an
unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
"""
def setUp(self):
# These cases intentionally exercise the subprocess/cache path, so they
# opt out of suite-wide hermetic mode (PR #788 F1).
self._saved_hermetic = mp.hermetic_test_mode()
mp.set_hermetic_test_mode(False)
mp._clear_remote_head_cache()
env = {
k: v for k, v in os.environ.items()
if k not in (mp.ENV_TEST_LIVE_REMOTE_HEAD,
mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE,
"PYTEST_CURRENT_TEST")
}
# Allow the probe path under hermetic defenses while still mocking
# subprocess so no real network call runs.
env[mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE] = "1"
self._env = patch.dict(os.environ, env, clear=True)
self._env.start()
self.addCleanup(self._env.stop)
self.addCleanup(mp._clear_remote_head_cache)
self.addCleanup(
lambda: mp.set_hermetic_test_mode(self._saved_hermetic)
)
def _fake_run(self, sha):
class _R:
returncode = 0
stdout = f"{sha}\trefs/heads/master\n"
calls = {"n": 0}
def run(*args, **kwargs):
calls["n"] += 1
return _R()
return run, calls
def test_second_call_within_ttl_uses_cache(self):
run, calls = self._fake_run(SHA_B)
with patch.object(mp.subprocess, "run", run):
a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
self.assertEqual(a, SHA_B)
self.assertEqual(b, SHA_B)
self.assertEqual(calls["n"], 1)
def test_zero_ttl_bypasses_cache(self):
run, calls = self._fake_run(SHA_B)
with patch.object(mp.subprocess, "run", run):
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
self.assertEqual(calls["n"], 2)
def test_env_override_never_touches_subprocess(self):
run, calls = self._fake_run(SHA_B)
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
with patch.object(mp.subprocess, "run", run):
self.assertEqual(
mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
self.assertEqual(calls["n"], 0)
class TestHermeticLiveRemoteReads(unittest.TestCase):
"""#610 / PR #788 F1/F2: suite hermetic mode never hits the network."""
def setUp(self):
self._saved = mp.hermetic_test_mode()
mp.set_hermetic_test_mode(True)
mp._clear_remote_head_cache()
self.addCleanup(lambda: mp.set_hermetic_test_mode(self._saved))
self.addCleanup(mp._clear_remote_head_cache)
def test_hermetic_mode_returns_none_without_subprocess(self):
run_calls = {"n": 0}
def boom(*args, **kwargs):
run_calls["n"] += 1
raise AssertionError("ls-remote must not run under hermetic mode")
env = {
k: v for k, v in os.environ.items()
if k not in (mp.ENV_TEST_LIVE_REMOTE_HEAD,
mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE)
}
with patch.dict(os.environ, env, clear=True):
with patch.object(mp.subprocess, "run", boom):
self.assertIsNone(
mp.read_remote_master_head("/repo", remote="prgs")
)
self.assertEqual(run_calls["n"], 0)
def test_hermetic_mode_survives_clear_true_env(self):
"""Module flag, not env pin: clear=True cannot re-enable the probe."""
run_calls = {"n": 0}
def boom(*args, **kwargs):
run_calls["n"] += 1
raise AssertionError("ls-remote must not run after clear=True")
with patch.dict(os.environ, {}, clear=True):
with patch.object(mp.subprocess, "run", boom):
self.assertIsNone(mp.read_remote_master_head("/repo"))
self.assertEqual(run_calls["n"], 0)
def test_explicit_override_still_wins_under_hermetic(self):
run_calls = {"n": 0}
def boom(*args, **kwargs):
run_calls["n"] += 1
raise AssertionError("override must bypass subprocess")
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
with patch.object(mp.subprocess, "run", boom):
self.assertEqual(
mp.read_remote_master_head("/repo"), SHA_B
)
self.assertEqual(run_calls["n"], 0)
class TestServerWiring(unittest.TestCase):
"""Integration with the gate choke point in the server namespace."""
@@ -105,6 +337,13 @@ class TestServerWiring(unittest.TestCase):
self._saved = self.srv._STARTUP_PARITY
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
"startup_head": SHA_A}
# Keep the live-remote read hermetic (no real ls-remote network call):
# default the live master to the daemon start so parity is fully green
# unless a test overrides the live head explicitly (#610).
self._live_patch = patch.dict(
os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
self._live_patch.start()
self.addCleanup(self._live_patch.stop)
def tearDown(self):
self.srv._STARTUP_PARITY = self._saved
@@ -147,6 +386,36 @@ class TestServerWiring(unittest.TestCase):
self.assertTrue(out["in_parity"])
self.assertNotIn("report", out)
# --- #610: live-remote wiring -------------------------------------------
def test_live_stale_blocks_mutation_though_local_green(self):
# Local checkout matches the daemon start (local parity green) but the
# live remote master has advanced -> mutations must fail closed.
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
self.assertTrue(
self.srv._master_parity_block("gitea.pr.create"))
def test_assess_tool_exposes_three_distinct_shas(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertEqual(out["daemon_start_head"], SHA_A)
self.assertEqual(out["local_head"], SHA_A)
self.assertEqual(out["live_remote_head"], SHA_B)
self.assertTrue(out["live_stale"])
self.assertFalse(out["mutation_safe"])
self.assertIn("report", out)
def test_assess_tool_mutation_safe_when_all_three_match(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertTrue(out["mutation_safe"])
self.assertFalse(out["live_stale"])
self.assertNotIn("report", out)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -10,6 +10,7 @@ DOCS = REPO_ROOT / "docs" / "mcp-menu.md"
REQUIRED_MENU_LABELS = (
"Project status / root checkout health",
"Workflow dashboard (queue, leases, next safe action)",
"Author workflow prompts",
"Reviewer workflow prompts",
"Merger workflow prompts",
@@ -105,6 +106,23 @@ class TestMcpMenuScript(unittest.TestCase):
self.assertIn("./mcp-menu.sh", docs_text)
self.assertIn("placeholder", docs_text.lower())
def test_workflow_dashboard_menu_entry_is_read_only(self):
# #605: dashboard entry documents gitea_workflow_dashboard and never
# mutates Gitea / assigns work from the shell menu.
label = "Workflow dashboard (queue, leases, next safe action)"
self.assertIn(label, self.content)
dash_fn = self._extract_function("show_workflow_dashboard_help")
self.assertIn("gitea_workflow_dashboard", dash_fn)
self.assertIn("gitea_allocate_next_work", dash_fn)
self.assertIn("Read-only", dash_fn)
self.assertIn("never presented as safe", dash_fn.lower())
for bad in ("gitea_merge_pr", "gitea_submit_pr_review", "git push"):
with self.subTest(bad=bad):
self.assertNotIn(bad, dash_fn)
docs_text = DOCS.read_text(encoding="utf-8")
self.assertIn("gitea_workflow_dashboard", docs_text)
self.assertIn("Workflow dashboard", docs_text)
def test_reviewer_skip_stale_request_changes_prompt_discoverable(self):
# #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt
# must be reachable from the reviewer menu and documented.
+388
View File
@@ -0,0 +1,388 @@
"""Tests for the #617 mutation-budget classifier.
Covers every acceptance criterion on issue #617:
* AC1 the classifier distinguishes local validator rejection, capability-gate
rejection, transport failure before API, and successful server-side mutation.
* AC2 pre-API validator failures do not consume server-side mutation budget.
* AC3 failed attempts are still logged in the local attempt ledger.
* AC4 the final report separately shows local failed attempts, blocked API
attempts, and successful server-side mutations.
* AC5 the six named scenarios, including the #615 reproduction where two
local validator rejections precede one successful comment.
"""
from __future__ import annotations
import unittest
from mutation_budget_classifier import (
AMBIGUOUS_REQUIRES_READBACK,
CAPABILITY_GATE_REJECTION,
LOCAL_VALIDATOR_REJECTION,
SERVER_SIDE_MUTATION,
TRANSPORT_FAILURE_BEFORE_API,
assess_final_report_mutation_accounting,
classify_mutation_attempt,
record_attempt,
summarize_attempt_ledger,
)
# The two pre-API rejections observed on the #615 comment flow.
MISSING_LEDGER_BLOCK = {
"success": False,
"performed": False,
"api_called": False,
"reasons": ["missing [THREAD STATE LEDGER] block"],
}
MISSING_CANONICAL_STATE = {
"success": False,
"performed": False,
"api_called": False,
"reasons": ["missing ## Canonical Issue State block"],
}
# The corrected comment that actually landed as #615 comment 9137.
SUCCESSFUL_COMMENT = {
"success": True,
"performed": True,
"api_called": True,
"comment_id": 9137,
"issue_number": 615,
}
class TestAC1Classification(unittest.TestCase):
"""AC1: the four outcome classes are distinguished."""
def test_local_validator_rejection_is_its_own_class(self):
result = classify_mutation_attempt(MISSING_LEDGER_BLOCK)
self.assertEqual(result["classification"], LOCAL_VALIDATOR_REJECTION)
self.assertTrue(result["pre_api"])
def test_capability_gate_rejection_is_its_own_class(self):
result = classify_mutation_attempt(
{
"success": False,
"api_called": False,
"permission_report": {"missing_permission": "gitea.issue.comment"},
}
)
self.assertEqual(result["classification"], CAPABILITY_GATE_REJECTION)
self.assertTrue(result["pre_api"])
def test_transport_failure_before_api_is_its_own_class(self):
result = classify_mutation_attempt(
{"success": False, "api_called": False, "transport_error": "EOF"}
)
self.assertEqual(result["classification"], TRANSPORT_FAILURE_BEFORE_API)
self.assertTrue(result["pre_api"])
def test_successful_server_mutation_is_its_own_class(self):
result = classify_mutation_attempt(SUCCESSFUL_COMMENT)
self.assertEqual(result["classification"], SERVER_SIDE_MUTATION)
self.assertFalse(result["pre_api"])
def test_each_class_is_distinct(self):
classes = {
classify_mutation_attempt(payload)["classification"]
for payload in (
MISSING_LEDGER_BLOCK,
{"success": False, "api_called": False, "capability_blocked": True},
{"success": False, "api_called": False, "transport_failed": True},
SUCCESSFUL_COMMENT,
)
}
self.assertEqual(len(classes), 4)
class TestAC2BudgetAccounting(unittest.TestCase):
"""AC2: pre-API failures never consume server-side mutation budget."""
def test_missing_thread_state_ledger_not_counted_as_mutation(self):
result = classify_mutation_attempt(MISSING_LEDGER_BLOCK)
self.assertFalse(result["budget_consumed"])
self.assertIs(result["api_called"], False)
def test_missing_canonical_issue_state_not_counted_as_mutation(self):
result = classify_mutation_attempt(MISSING_CANONICAL_STATE)
self.assertFalse(result["budget_consumed"])
self.assertIs(result["api_called"], False)
def test_transport_failure_before_api_not_counted_as_mutation(self):
result = classify_mutation_attempt(
{
"success": False,
"api_called": False,
"transport_error": "connection reset",
}
)
self.assertFalse(result["budget_consumed"])
def test_capability_gate_block_not_counted_as_mutation(self):
result = classify_mutation_attempt(
{
"success": False,
"api_called": False,
"permission_report": {"missing_permission": "gitea.pr.merge"},
}
)
self.assertFalse(result["budget_consumed"])
def test_successful_comment_with_comment_id_counts_as_one_mutation(self):
result = classify_mutation_attempt(SUCCESSFUL_COMMENT)
self.assertTrue(result["budget_consumed"])
self.assertEqual(result["proof_fields"], ["comment_id"])
class TestAC2FailsClosed(unittest.TestCase):
"""AC2 must not become a loophole: ambiguity still fails closed."""
def test_api_reached_without_proof_is_ambiguous_and_consumes_budget(self):
result = classify_mutation_attempt({"success": True, "api_called": True})
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
self.assertTrue(result["budget_consumed"])
self.assertTrue(result["requires_readback"])
def test_missing_api_called_signal_fails_closed(self):
result = classify_mutation_attempt({"success": False})
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
self.assertTrue(result["budget_consumed"])
self.assertIsNone(result["api_called"])
def test_empty_and_none_results_fail_closed(self):
for payload in ({}, None):
result = classify_mutation_attempt(payload)
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
self.assertTrue(result["budget_consumed"])
def test_success_with_proof_counts_even_when_api_called_absent(self):
result = classify_mutation_attempt({"success": True, "comment_id": 13320})
self.assertEqual(result["classification"], SERVER_SIDE_MUTATION)
self.assertTrue(result["budget_consumed"])
def test_blank_proof_field_is_not_proof(self):
result = classify_mutation_attempt(
{"success": True, "api_called": True, "merge_commit_sha": " "}
)
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
class TestAC3AttemptLedger(unittest.TestCase):
"""AC3: failed attempts are still logged locally."""
def test_failed_attempts_are_recorded(self):
ledger: list[dict] = []
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="create_issue_comment")
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="create_issue_comment")
self.assertEqual(len(ledger), 2)
self.assertTrue(
all(e["classification"] == LOCAL_VALIDATOR_REJECTION for e in ledger)
)
def test_recorded_entry_carries_operation_and_timestamp(self):
ledger: list[dict] = []
entry = record_attempt(
ledger,
SUCCESSFUL_COMMENT,
operation="create_issue_comment",
timestamp="2026-07-20T18:15:04+00:00",
)
self.assertEqual(entry["operation"], "create_issue_comment")
self.assertEqual(entry["timestamp"], "2026-07-20T18:15:04+00:00")
def test_timestamp_is_generated_when_omitted(self):
ledger: list[dict] = []
entry = record_attempt(ledger, SUCCESSFUL_COMMENT)
self.assertTrue(entry["timestamp"])
class TestAC5CorrectedCommentAllowed(unittest.TestCase):
"""AC5: the #615 reproduction — two local rejections then one success."""
def _replay_615_flow(self) -> list[dict]:
ledger: list[dict] = []
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="create_issue_comment")
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="create_issue_comment")
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="create_issue_comment")
return ledger
def test_corrected_comment_after_two_rejections_is_allowed(self):
summary = summarize_attempt_ledger(self._replay_615_flow())
# The regression: budget must show ONE mutation, not three attempts.
self.assertEqual(summary["successful_server_mutations"], 1)
self.assertEqual(summary["budget_consumed"], 1)
def test_all_three_attempts_remain_visible(self):
summary = summarize_attempt_ledger(self._replay_615_flow())
self.assertEqual(summary["total_attempts"], 3)
self.assertEqual(summary["local_failed_attempts"], 2)
def test_no_readback_required_for_clean_flow(self):
summary = summarize_attempt_ledger(self._replay_615_flow())
self.assertFalse(summary["requires_readback"])
class TestAC4FinalReportAccounting(unittest.TestCase):
"""AC4: the report must show the three categories, and match the ledger."""
def _mixed_ledger(self) -> list[dict]:
ledger: list[dict] = []
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="comment")
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="comment")
record_attempt(
ledger,
{"success": False, "api_called": False, "transport_error": "EOF"},
operation="comment",
)
record_attempt(
ledger,
{"success": False, "api_called": False, "capability_blocked": True},
operation="merge",
)
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="comment")
return ledger
def test_summary_separates_the_three_categories(self):
summary = summarize_attempt_ledger(self._mixed_ledger())
self.assertEqual(summary["local_failed_attempts"], 2)
self.assertEqual(summary["blocked_api_attempts"], 2)
self.assertEqual(summary["successful_server_mutations"], 1)
def test_matching_report_is_valid(self):
result = assess_final_report_mutation_accounting(
{
"local_failed_attempts": 2,
"blocked_api_attempts": 2,
"successful_server_mutations": 1,
},
self._mixed_ledger(),
)
self.assertTrue(result["valid"], result["reasons"])
def test_omitted_category_fails_closed(self):
result = assess_final_report_mutation_accounting(
{"local_failed_attempts": 2, "blocked_api_attempts": 2},
self._mixed_ledger(),
)
self.assertFalse(result["valid"])
self.assertTrue(
any("successful_server_mutations" in r for r in result["reasons"])
)
def test_inflated_mutation_count_fails_closed(self):
# The #617 bug shape: claiming three mutations when only one landed.
result = assess_final_report_mutation_accounting(
{
"local_failed_attempts": 2,
"blocked_api_attempts": 2,
"successful_server_mutations": 3,
},
self._mixed_ledger(),
)
self.assertFalse(result["valid"])
self.assertTrue(
any("successful_server_mutations=3" in r for r in result["reasons"])
)
def test_ambiguous_attempt_requires_readback_proof(self):
ledger: list[dict] = []
record_attempt(ledger, {"success": True, "api_called": True}, operation="comment")
report = {
"local_failed_attempts": 0,
"blocked_api_attempts": 0,
"successful_server_mutations": 0,
}
blocked = assess_final_report_mutation_accounting(report, ledger)
self.assertFalse(blocked["valid"])
self.assertTrue(any("readback_verified" in r for r in blocked["reasons"]))
allowed = assess_final_report_mutation_accounting(
{**report, "readback_verified": True}, ledger
)
self.assertTrue(allowed["valid"], allowed["reasons"])
def test_ledger_summary_is_returned_without_raw_entries(self):
result = assess_final_report_mutation_accounting({}, self._mixed_ledger())
self.assertNotIn("entries", result["ledger_summary"])
self.assertEqual(result["ledger_summary"]["total_attempts"], 5)
class TestEmptyLedger(unittest.TestCase):
def test_empty_ledger_summarizes_to_zero(self):
summary = summarize_attempt_ledger([])
self.assertEqual(summary["total_attempts"], 0)
self.assertEqual(summary["successful_server_mutations"], 0)
self.assertFalse(summary["requires_readback"])
def test_none_ledger_is_tolerated(self):
self.assertEqual(summarize_attempt_ledger(None)["total_attempts"], 0)
if __name__ == "__main__":
unittest.main()
class TestValidatorIntegration(unittest.TestCase):
"""The classifier is wired into the shared final-report validator (AC4)."""
def _ledger_two_rejections_one_success(self) -> list[dict]:
ledger: list[dict] = []
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="comment")
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="comment")
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="comment")
return ledger
def test_rule_is_noop_without_a_ledger(self):
from final_report_validator import assess_final_report_validator
result = assess_final_report_validator("some report", "review_pr")
self.assertFalse(
any(
f["rule_id"] == "shared.mutation_budget_accounting"
for f in result["findings"]
)
)
def test_report_matching_ledger_produces_no_finding(self):
from final_report_validator import assess_final_report_validator
report = (
"Local failed attempts: 2\n"
"Blocked API attempts: 0\n"
"Successful server-side mutations: 1\n"
)
result = assess_final_report_validator(
report,
"review_pr",
mutation_attempt_ledger=self._ledger_two_rejections_one_success(),
)
self.assertFalse(
any(
f["rule_id"] == "shared.mutation_budget_accounting"
for f in result["findings"]
)
)
def test_counting_rejections_as_mutations_is_blocked(self):
from final_report_validator import assess_final_report_validator
# The #617 bug: three attempts reported as three server-side mutations.
report = (
"Local failed attempts: 0\n"
"Blocked API attempts: 0\n"
"Successful server-side mutations: 3\n"
)
result = assess_final_report_validator(
report,
"review_pr",
mutation_attempt_ledger=self._ledger_two_rejections_one_success(),
)
findings = [
f
for f in result["findings"]
if f["rule_id"] == "shared.mutation_budget_accounting"
]
self.assertTrue(findings)
self.assertTrue(all(f["severity"] == "block" for f in findings))
+14 -3
View File
@@ -224,9 +224,20 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master"},
):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity("prgs")
self.assertIn("stable control checkout", str(ctx.exception))
with mock.patch(
"gitea_mcp_server._session_author_lock_worktree",
return_value=None,
):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity("prgs")
blob = str(ctx.exception)
self.assertTrue(
"stable control checkout" in blob
or "control checkout" in blob
or "#618" in blob
or "author worktree" in blob.lower(),
msg=blob,
)
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
+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()
+70 -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,45 @@ 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",
# #812 AC20: publishing an unpublished local head is author-only for the
# same reason every other push is — it writes a branch to the remote.
"publish_unpublished_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 +245,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()
+149
View File
@@ -0,0 +1,149 @@
"""Documentation acceptance for the web console architecture ADR (#632 / epic #631).
Enforces the acceptance criteria of issue #632:
* AC1 the ADR exists and covers layers, authority, phases, API versioning,
and a page map.
* AC2 every #631 child (#632#651) maps to at least one architectural
component.
* AC3 the closed MVP (#425#436) is stated as foundation, not recreated.
* AC4 forbidden paths are explicit: raw provider incidents as work,
browser-held tokens, process-kill recovery.
* AC5 a controller can approve the document without reading chat history.
Plus the linkage requirement: ``docs/webui-local-dev.md`` cross-links the ADR.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
ADR = (
REPO_ROOT
/ "docs"
/ "architecture"
/ "webui-control-plane-console-architecture-adr.md"
)
ADR_BASENAME = "webui-control-plane-console-architecture-adr.md"
LOCAL_DEV = REPO_ROOT / "docs" / "webui-local-dev.md"
# Epic #631 children, phases 1-4 (twenty capability areas).
EPIC_CHILDREN = tuple(f"#{number}" for number in range(632, 652))
def _read(path: Path) -> str:
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
return path.read_text(encoding="utf-8")
def test_ac1_adr_exists_with_required_sections():
text = _read(ADR)
lower = text.lower()
assert text.lstrip().startswith("#"), "ADR lacks a title"
assert "#631" in text and "#632" in text
for heading in (
"## 2. Decision summary",
"## 4. Authority boundaries",
"## 5. Request flow and the redaction boundary",
"## 6. API naming and versioning",
"## 7. Page map",
"## 8. Component ownership",
"## 9. Phase gates",
"## 11. Forbidden paths",
):
assert heading in text, f"ADR must contain section {heading!r}"
assert "browser ui" in lower and "domain loader" in lower
assert "control-plane db" in lower and "capability gate" in lower
def test_ac1_api_versioning_is_decided_including_legacy_routes():
text = _read(ADR)
assert "/api/v1/" in text, "ADR must decide the versioned API prefix"
assert "/api/v2/" in text, "ADR must state how breaking changes are handled"
lower = text.lower()
assert "compatibility alias" in lower, (
"ADR must say what happens to the existing unversioned MVP exports"
)
def test_ac1_page_map_covers_mvp_routes():
text = _read(ADR)
for route in ("`/`", "`/health`", "`/projects`", "`/prompts`", "`/runtime`",
"`/audit`", "`/actions`"):
assert route in text, f"page map must account for MVP route {route}"
def test_ac2_every_epic_child_maps_to_a_component():
text = _read(ADR)
ownership = text.split("## 8. Component ownership", 1)[-1].split("## 9.", 1)[0]
missing = [child for child in EPIC_CHILDREN if child not in ownership]
assert not missing, (
f"epic #631 children without an architectural component: {missing}"
)
def test_ac2_every_child_row_declares_a_phase():
text = _read(ADR)
ownership = text.split("## 8. Component ownership", 1)[-1].split("## 9.", 1)[0]
for child in EPIC_CHILDREN:
row = next(
(line for line in ownership.splitlines() if line.startswith(f"| {child} ")),
None,
)
assert row is not None, f"no ownership row for {child}"
assert row.rstrip().endswith(("| 1 |", "| 2 |", "| 3 |", "| 4 |")), (
f"ownership row for {child} must end with its phase: {row!r}"
)
def test_ac3_mvp_is_foundation_not_recreated():
text = _read(ADR)
assert "#425" in text and "#436" in text
lower = text.lower()
assert "do not recreate" in lower or "recreating mvp scope" in lower
assert "retained and evolved" in lower
def test_ac4_forbidden_paths_are_explicit():
text = _read(ADR)
forbidden = text.split("## 11. Forbidden paths", 1)[-1].split("## 12.", 1)[0]
lower = forbidden.lower()
assert "raw provider incidents" in lower and "#612" in forbidden
assert "browser-held tokens" in lower
assert "process-kill recovery" in lower and "#630" in forbidden
assert "ungated browser mutations" in lower
def test_ac5_approval_checklist_is_self_contained():
text = _read(ADR)
assert "## 12. Approval checklist" in text
checklist = text.split("## 12. Approval checklist", 1)[-1].split("## 13.", 1)[0]
for marker in ("1.", "2.", "3.", "4.", "5.", "6."):
assert marker in checklist, f"approval checklist missing item {marker}"
def test_adr_states_the_two_boundary_invariants():
text = _read(ADR)
lower = text.lower()
assert "no secrets to the browser" in lower
assert "no ungated mutations" in lower
def test_open_questions_are_recorded_not_implied():
text = _read(ADR)
assert "## 13. Open questions and follow-ups" in text
section = text.split("## 13. Open questions and follow-ups", 1)[-1]
assert "#633" in section, "deferred authorization work must name its issue"
def test_local_dev_doc_cross_links_the_adr():
text = _read(LOCAL_DEV)
assert ADR_BASENAME in text, (
"docs/webui-local-dev.md must cross-link the console architecture ADR "
"(issue #632 scope)"
)
def test_docs_do_not_embed_secrets():
for path in (ADR, LOCAL_DEV):
text = _read(path)
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
assert marker not in text, f"{path.name} contains {marker!r}"
+703
View File
@@ -0,0 +1,703 @@
"""Console authorization, redaction, and audit model tests (#633).
Covers each acceptance criterion and each required test named in the issue:
* AC1 RBAC matrix and privileged-action list.
* AC2 redaction rules, unit-tested against sample payloads.
* AC3 audit event schema with required fields and retention defaults.
* AC4 Phase 2 integration points.
* AC5 local-dev mode with explicit insecurity warnings.
Required tests: redaction units (token, keychain, password patterns),
default-deny for unauthenticated write stubs, and audit record creation for a
simulated privileged preview.
"""
from __future__ import annotations
import datetime
import json
import os
import pathlib
import sys
import tempfile
import unittest
from starlette.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from task_capability_map import TASK_CAPABILITY_MAP # noqa: E402
from webui import console_audit, console_authz # noqa: E402
from webui.app import create_app # noqa: E402
from webui.console_redaction import ( # noqa: E402
REDACTED,
redact_payload,
redact_text,
redaction_policy,
scan_for_secrets,
)
DOCS = pathlib.Path(__file__).resolve().parents[1] / "docs"
AUTHZ_DOC = DOCS / "webui-authz-audit.md"
def _principal(role: str) -> console_authz.Principal:
return console_authz.Principal(
subject=f"{role}@example.com",
role=role,
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
authenticated=True,
)
class TestRoleMatrix(unittest.TestCase):
"""AC1 — the written RBAC matrix and privileged-action list."""
def test_roles_are_ordered_least_to_most_authority(self):
self.assertEqual(
console_authz.ROLE_ORDER,
("viewer", "operator", "controller", "admin"),
)
def test_every_role_has_a_description(self):
for role in console_authz.ROLE_ORDER:
with self.subTest(role=role):
self.assertTrue(console_authz.ROLE_DESCRIPTIONS[role].strip())
def test_higher_roles_inherit_lower_role_actions(self):
matrix = {
entry["role"]: set(entry["permitted_actions"])
for entry in console_authz.rbac_matrix()["roles"]
}
for lower, higher in zip(
console_authz.ROLE_ORDER, console_authz.ROLE_ORDER[1:]
):
with self.subTest(lower=lower, higher=higher):
self.assertTrue(matrix[lower].issubset(matrix[higher]))
def test_viewer_holds_no_write_action(self):
matrix = {
entry["role"]: set(entry["permitted_actions"])
for entry in console_authz.rbac_matrix()["roles"]
}
self.assertEqual(matrix["viewer"], set())
def test_privileged_action_list_is_non_empty_and_classified(self):
privileged = console_authz.privileged_actions()
self.assertTrue(privileged)
ids = {action.action_id for action in privileged}
# Merge and branch deletion are the canonical privileged pair.
self.assertIn("merge_pr", ids)
self.assertIn("delete_branch", ids)
def test_merge_and_delete_require_dual_control_and_break_glass(self):
for action_id in ("merge_pr", "delete_branch"):
with self.subTest(action=action_id):
action = console_authz.get_action(action_id)
self.assertTrue(action.dual_control)
self.assertTrue(action.break_glass)
self.assertTrue(action.requires_confirmation)
def test_every_write_action_requires_confirmation(self):
for action in console_authz.ACTIONS.values():
with self.subTest(action=action.action_id):
self.assertTrue(action.requires_confirmation)
def test_delete_branch_is_admin_only(self):
self.assertEqual(
console_authz.get_action("delete_branch").minimum_role,
console_authz.ADMIN,
)
def test_actions_map_to_real_mcp_capability_vocabulary(self):
"""The console must not invent an authority the MCP layer lacks."""
for action in console_authz.ACTIONS.values():
with self.subTest(action=action.action_id):
self.assertIn(action.task_key, TASK_CAPABILITY_MAP)
self.assertEqual(
action.mcp_permission,
TASK_CAPABILITY_MAP[action.task_key]["permission"],
)
self.assertEqual(
action.mcp_role,
TASK_CAPABILITY_MAP[action.task_key]["role"],
)
def test_matrix_declares_deny_by_default_and_execution_disabled(self):
matrix = console_authz.rbac_matrix()
self.assertEqual(matrix["default_decision"], "deny")
self.assertFalse(matrix["execution_enabled"])
class TestAuthorizeDefaultDeny(unittest.TestCase):
"""Fail-closed behaviour of the authorization decision."""
def test_anonymous_is_denied_every_action(self):
for action_id in console_authz.ACTIONS:
with self.subTest(action=action_id):
decision = console_authz.authorize(action_id)
self.assertFalse(decision.allowed)
self.assertEqual(
decision.reason_code, console_authz.DENY_UNAUTHENTICATED
)
def test_unknown_action_is_denied(self):
decision = console_authz.authorize(
"not_a_real_action", _principal("admin")
)
self.assertFalse(decision.allowed)
self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ACTION)
def test_unknown_role_is_denied(self):
rogue = console_authz.Principal(
subject="[email protected]",
role="superuser",
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
authenticated=True,
)
decision = console_authz.authorize("comment_issue", rogue)
self.assertFalse(decision.allowed)
self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ROLE)
def test_insufficient_role_is_denied(self):
decision = console_authz.authorize("merge_pr", _principal("operator"))
self.assertFalse(decision.allowed)
self.assertEqual(
decision.reason_code, console_authz.DENY_INSUFFICIENT_ROLE
)
def test_sufficient_role_allows_preview_only(self):
decision = console_authz.authorize("merge_pr", _principal("controller"))
self.assertTrue(decision.allowed)
self.assertFalse(decision.execution_enabled)
def test_execution_is_refused_while_phase_is_not_active(self):
decision = console_authz.authorize(
"merge_pr", _principal("controller"), for_execution=True
)
self.assertFalse(decision.allowed)
self.assertEqual(
decision.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE
)
def test_allowed_decision_never_reports_execution_enabled(self):
for action_id in console_authz.ACTIONS:
with self.subTest(action=action_id):
decision = console_authz.authorize(
action_id, _principal("admin")
)
self.assertFalse(decision.execution_enabled)
class TestIdentityResolution(unittest.TestCase):
"""AC5 — identity sources, including the insecure local-dev mode."""
def test_no_auth_mode_yields_anonymous_viewer(self):
principal = console_authz.resolve_principal(env={})
self.assertFalse(principal.authenticated)
self.assertEqual(principal.role, console_authz.VIEWER)
self.assertEqual(principal.identity_source, console_authz.IDENTITY_NONE)
def test_local_dev_mode_warns_that_identity_is_unverified(self):
principal = console_authz.resolve_principal(
env={
console_authz.AUTH_MODE_ENV: "local-dev",
console_authz.DEV_SUBJECT_ENV: "[email protected]",
console_authz.DEV_ROLE_ENV: "admin",
}
)
self.assertTrue(principal.authenticated)
self.assertEqual(principal.role, "admin")
self.assertTrue(principal.warnings)
self.assertIn("asserted", " ".join(principal.warnings).lower())
def test_local_dev_without_subject_falls_back_to_anonymous(self):
principal = console_authz.resolve_principal(
env={console_authz.AUTH_MODE_ENV: "local-dev"}
)
self.assertFalse(principal.authenticated)
def test_local_dev_unknown_role_degrades_to_viewer(self):
principal = console_authz.resolve_principal(
env={
console_authz.AUTH_MODE_ENV: "local_dev",
console_authz.DEV_SUBJECT_ENV: "[email protected]",
console_authz.DEV_ROLE_ENV: "root",
}
)
self.assertEqual(principal.role, console_authz.VIEWER)
def test_access_proxy_without_header_fails_closed(self):
"""A proxy-mode request that did not traverse the proxy is anonymous."""
principal = console_authz.resolve_principal(
headers={},
env={console_authz.AUTH_MODE_ENV: "access_proxy"},
)
self.assertFalse(principal.authenticated)
def test_access_proxy_role_comes_from_server_config_not_client(self):
env = {
console_authz.AUTH_MODE_ENV: "access_proxy",
console_authz.ROLE_MAP_ENV: json.dumps(
{"[email protected]": "controller"}
),
}
principal = console_authz.resolve_principal(
headers={
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]",
"x-role": "admin", # client-supplied role must be ignored
},
env=env,
)
self.assertEqual(principal.role, "controller")
def test_access_proxy_unmapped_subject_defaults_to_viewer(self):
principal = console_authz.resolve_principal(
headers={
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]"
},
env={console_authz.AUTH_MODE_ENV: "access_proxy"},
)
self.assertEqual(principal.role, console_authz.VIEWER)
def test_malformed_role_map_does_not_raise_and_denies(self):
principal = console_authz.resolve_principal(
headers={console_authz.ACCESS_SUBJECT_HEADER: "[email protected]"},
env={
console_authz.AUTH_MODE_ENV: "access_proxy",
console_authz.ROLE_MAP_ENV: "{not json",
},
)
self.assertEqual(principal.role, console_authz.VIEWER)
def test_probe_auth_is_opt_in(self):
self.assertFalse(console_authz.probe_auth_required(env={}))
self.assertTrue(
console_authz.probe_auth_required(
env={console_authz.REQUIRE_PROBE_AUTH_ENV: "1"}
)
)
def test_probe_auth_is_declared_but_not_yet_enforced(self):
"""Phase 1 declares the probe-auth policy; no route enforces it yet.
The flag exists so the Phase 2 action framework has a declared policy
to honour instead of inventing a second one. Pinning the current
not-enforced status here means wiring it later is a deliberate change
that updates this test and the documentation together, rather than a
silent behaviour shift. The documentation must say so plainly, because
an operator who sets the variable believing it protects a probe is
worse off than one who knows it does not.
"""
import inspect
from webui import app as webui_app
source = inspect.getsource(webui_app)
self.assertNotIn(
"probe_auth_required",
source,
msg=(
"webui.app now consults probe_auth_required, so probe auth is "
"no longer merely declared. Update the 'Probe authentication' "
"section of docs/webui-authz-audit.md, which states it "
"enforces nothing, and replace this test with real "
"enforcement coverage."
),
)
self.assertIn(
"enforces nothing today",
AUTHZ_DOC.read_text(encoding="utf-8"),
)
class TestRedaction(unittest.TestCase):
"""AC2 — required redaction units: token, keychain, password patterns."""
def test_token_assignment_is_redacted(self):
out = redact_text("GITEA_TOKEN=abcd1234efgh5678ijkl")
self.assertIn(REDACTED, out)
self.assertNotIn("abcd1234efgh5678ijkl", out)
def test_password_assignment_is_redacted(self):
out = redact_text("password: hunter2supersecret")
self.assertIn(REDACTED, out)
self.assertNotIn("hunter2supersecret", out)
def test_keychain_reference_is_redacted(self):
out = redact_text("keychain:gitea-prgs-token")
self.assertIn(REDACTED, out)
self.assertNotIn("gitea-prgs-token", out)
def test_keychain_command_is_redacted(self):
out = redact_text("security find-generic-password -s gitea -w")
self.assertIn(REDACTED, out)
self.assertNotIn("find-generic-password -s gitea", out)
def test_bearer_credential_is_redacted(self):
out = redact_text("Authorization: Bearer abcdef1234567890abcdef")
self.assertNotIn("abcdef1234567890abcdef", out)
def test_jwt_is_redacted(self):
token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop"
out = redact_text(f"session={token}")
self.assertNotIn(token, out)
def test_private_key_block_is_redacted(self):
pem = (
"-----BEGIN RSA PRIVATE KEY-----\n"
"MIIEowIBAAKCAQEAsecretmaterial\n"
"-----END RSA PRIVATE KEY-----"
)
out = redact_text(pem)
self.assertNotIn("MIIEowIBAAKCAQEAsecretmaterial", out)
def test_api_key_assignment_is_redacted(self):
out = redact_text('api_key = "sk-live-9f8e7d6c5b4a3210"')
self.assertNotIn("sk-live-9f8e7d6c5b4a3210", out)
def test_nested_payload_is_redacted_recursively(self):
payload = {
"token": "abc123456789",
"nested": {"note": "password=letmein12345"},
"list": ["keychain:some-entry"],
"safe": "plain text",
}
out = redact_payload(payload)
self.assertEqual(out["token"], REDACTED)
self.assertNotIn("letmein12345", json.dumps(out))
self.assertNotIn("some-entry", json.dumps(out))
self.assertEqual(out["safe"], "plain text")
def test_scan_reports_findings_before_and_none_after(self):
dirty = "password: hunter2supersecret"
self.assertTrue(scan_for_secrets(dirty))
self.assertEqual(scan_for_secrets(redact_text(dirty)), [])
def test_non_strings_pass_through_untouched(self):
self.assertEqual(redact_text(42), 42)
self.assertEqual(
redact_payload({"n": 1, "b": True}), {"n": 1, "b": True}
)
def test_policy_is_documented_and_declares_redact_before_persist(self):
policy = redaction_policy()
self.assertTrue(policy["redact_before_persist"])
self.assertIn("audit_records", policy["applies_to"])
self.assertTrue(policy["console_rules"])
def test_policy_statement_contains_no_secret_material(self):
self.assertEqual(scan_for_secrets(redaction_policy()), [])
class TestAuditSchema(unittest.TestCase):
"""AC3 — audit event schema, required fields, and retention defaults."""
def _event(self, action_id="merge_pr", **kwargs):
return console_audit.build_event(
action_id=action_id,
result=console_audit.RESULT_DENIED,
decision=console_authz.authorize(action_id, _principal("operator")),
target={"kind": "pr", "ref": "#123"},
request_id="req-test",
**kwargs,
)
def test_every_required_field_is_present(self):
event = self._event()
for field in console_audit.REQUIRED_FIELDS:
with self.subTest(field=field):
self.assertIn(field, event)
def test_actor_carries_who_and_how_they_were_identified(self):
event = self._event()
for field in console_audit.REQUIRED_ACTOR_FIELDS:
with self.subTest(field=field):
self.assertIn(field, event["actor"])
def test_correlation_ids_are_present(self):
event = self._event()
for field in console_audit.REQUIRED_CORRELATION_FIELDS:
with self.subTest(field=field):
self.assertIn(field, event["correlation"])
self.assertEqual(event["correlation"]["request_id"], "req-test")
self.assertEqual(event["correlation"]["mcp_task"], "merge_pr")
def test_timestamp_is_timezone_aware_utc_iso8601(self):
now = datetime.datetime(
2026, 7, 22, 10, 16, 42, tzinfo=datetime.timezone.utc
)
event = self._event(now=now)
self.assertEqual(event["timestamp"], "2026-07-22T10:16:42+00:00")
parsed = datetime.datetime.fromisoformat(event["timestamp"])
self.assertIsNotNone(parsed.tzinfo)
def test_retention_defaults_by_class(self):
self.assertEqual(
console_audit.RETENTION_DAYS[console_audit.RETENTION_STANDARD], 90
)
self.assertEqual(
console_audit.RETENTION_DAYS[console_audit.RETENTION_PRIVILEGED],
365,
)
self.assertEqual(
console_audit.RETENTION_DAYS[console_audit.RETENTION_BREAK_GLASS],
730,
)
def test_break_glass_action_retains_longest(self):
event = self._event("merge_pr")
self.assertEqual(
event["retention"]["class"], console_audit.RETENTION_BREAK_GLASS
)
def test_routine_write_uses_standard_retention(self):
event = self._event("comment_issue")
self.assertEqual(
event["retention"]["class"], console_audit.RETENTION_STANDARD
)
def test_unknown_action_retains_as_privileged_not_standard(self):
"""Conservative direction: keep an unclassifiable record longer."""
self.assertEqual(
console_audit.retention_class_for(None),
console_audit.RETENTION_PRIVILEGED,
)
def test_retention_expiry_matches_declared_days(self):
now = datetime.datetime(2026, 7, 22, tzinfo=datetime.timezone.utc)
event = self._event("comment_issue", now=now)
expires = datetime.datetime.fromisoformat(
event["retention"]["expires_at"]
)
self.assertEqual((expires - now).days, 90)
def test_invalid_result_degrades_to_failed(self):
event = console_audit.build_event(action_id="merge_pr", result="banana")
self.assertEqual(event["result"], console_audit.RESULT_FAILED)
def test_denied_result_is_representable(self):
"""An authorization denial has no MCP-side mutation record."""
self.assertIn(console_audit.RESULT_DENIED, console_audit.RESULTS)
def test_event_is_redacted_before_it_is_returned(self):
event = console_audit.build_event(
action_id="merge_pr",
result=console_audit.RESULT_DENIED,
detail="failed with token=abcdef1234567890",
metadata={"password": "hunter2supersecret"},
)
serialized = json.dumps(event)
self.assertNotIn("abcdef1234567890", serialized)
self.assertNotIn("hunter2supersecret", serialized)
self.assertTrue(event["redacted"])
def test_audit_policy_reports_schema_and_retention(self):
policy = console_audit.audit_policy()
self.assertTrue(policy["append_only"])
self.assertTrue(policy["redact_before_persist"])
self.assertEqual(
policy["retention_defaults_days"], console_audit.RETENTION_DAYS
)
class TestAuditSink(unittest.TestCase):
"""Append-only persistence behaviour."""
def test_write_is_a_noop_when_sink_is_unconfigured(self):
saved = os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
try:
self.assertFalse(console_audit.audit_enabled())
self.assertFalse(console_audit.write_event({"schema_version": 1}))
finally:
if saved is not None:
os.environ[console_audit.AUDIT_LOG_ENV] = saved
def test_records_append_one_json_line_each(self):
with tempfile.TemporaryDirectory() as tmp:
sink = os.path.join(tmp, "console-audit.jsonl")
for _ in range(3):
event = console_audit.build_event(
action_id="merge_pr", result=console_audit.RESULT_DENIED
)
self.assertTrue(console_audit.write_event(event, path=sink))
with open(sink, encoding="utf-8") as handle:
lines = [json.loads(line) for line in handle if line.strip()]
self.assertEqual(len(lines), 3)
self.assertEqual(len({line["event_id"] for line in lines}), 3)
def test_a_record_that_still_carries_a_secret_is_not_persisted(self):
with tempfile.TemporaryDirectory() as tmp:
sink = os.path.join(tmp, "console-audit.jsonl")
leaky = {
"schema_version": 1,
"detail": "password: hunter2supersecret",
}
self.assertFalse(console_audit.write_event(leaky, path=sink))
self.assertFalse(os.path.exists(sink))
def test_write_never_raises_on_a_bad_path(self):
self.assertFalse(
console_audit.write_event(
{"schema_version": 1}, path="/nonexistent-dir/audit.jsonl"
)
)
def test_simulated_privileged_preview_creates_an_audit_record(self):
"""Required test: audit record creation for a privileged preview."""
with tempfile.TemporaryDirectory() as tmp:
sink = os.path.join(tmp, "console-audit.jsonl")
os.environ[console_audit.AUDIT_LOG_ENV] = sink
try:
decision = console_authz.authorize(
"merge_pr", _principal("controller")
)
outcome = console_audit.record_event(
action_id="merge_pr",
result=console_audit.RESULT_PREVIEWED,
decision=decision,
target={"kind": "pr", "ref": "#123"},
request_id="req-preview",
)
finally:
os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
self.assertTrue(outcome["written"])
with open(sink, encoding="utf-8") as handle:
record = json.loads(handle.read().strip())
self.assertEqual(record["action"], "merge_pr")
self.assertEqual(record["result"], console_audit.RESULT_PREVIEWED)
self.assertEqual(record["action_class"], "privileged")
self.assertTrue(record["decision"]["allowed"])
self.assertFalse(record["decision"]["execution_enabled"])
self.assertEqual(record["actor"]["role"], "controller")
def test_decision_block_survives_redaction(self):
"""Regression: naming it 'authorization' collided with a secret hint.
``gitea_audit._SECRET_KEY_HINTS`` contains "authorization" (for the
HTTP header), so a block under that key was replaced wholesale by the
placeholder and the record lost its decision entirely.
"""
event = console_audit.build_event(
action_id="merge_pr",
result=console_audit.RESULT_DENIED,
decision=console_authz.authorize("merge_pr", _principal("admin")),
)
self.assertIsInstance(event["decision"], dict)
self.assertIn("allowed", event["decision"])
class TestConsoleRoutes(unittest.TestCase):
"""AC4 — the wired Phase 2 integration points, still fail-closed."""
def setUp(self):
self.client = TestClient(create_app(bind_host="127.0.0.1"))
def test_unauthenticated_write_stub_is_denied(self):
"""Required test: default-deny for unauthenticated write stubs."""
response = self.client.post(
"/api/actions/merge_pr/attempt", json={"pr_number": 99}
)
self.assertEqual(response.status_code, 403)
body = response.json()
self.assertFalse(body["success"])
authorization = body["authorization"]
self.assertFalse(authorization["allowed"])
self.assertEqual(
authorization["reason_code"], console_authz.DENY_UNAUTHENTICATED
)
self.assertFalse(authorization["execution_enabled"])
def test_preview_reports_an_authorization_decision(self):
response = self.client.get("/api/actions/merge_pr/preview?pr_number=7")
self.assertEqual(response.status_code, 200)
authorization = response.json()["authorization"]
self.assertFalse(authorization["allowed"])
self.assertTrue(authorization["dual_control"])
self.assertEqual(authorization["required_role"], "controller")
def test_unknown_action_preview_still_404s(self):
response = self.client.get("/api/actions/no_such_action/preview")
self.assertEqual(response.status_code, 404)
def test_security_model_endpoint_publishes_all_three_policies(self):
response = self.client.get("/api/console/security-model")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertIn("rbac", body)
self.assertIn("redaction", body)
self.assertIn("audit", body)
self.assertEqual(body["rbac"]["default_decision"], "deny")
def test_security_model_endpoint_leaks_no_secrets(self):
response = self.client.get("/api/console/security-model")
self.assertEqual(scan_for_secrets(response.json()), [])
def test_security_model_rejects_writes(self):
response = self.client.post("/api/console/security-model", json={})
self.assertEqual(response.status_code, 405)
def test_existing_read_routes_are_unaffected(self):
for path in ("/", "/health", "/actions", "/api/actions"):
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 200)
class TestAuthzAuditDoc(unittest.TestCase):
"""The model must be written down, not only coded."""
@classmethod
def setUpClass(cls):
cls.text = (
AUTHZ_DOC.read_text(encoding="utf-8") if AUTHZ_DOC.exists() else ""
)
def test_doc_exists(self):
self.assertTrue(AUTHZ_DOC.exists(), f"missing {AUTHZ_DOC}")
def test_doc_covers_each_required_section(self):
for heading in (
"Identity sources",
"Role matrix",
"Privileged actions",
"Secret redaction",
"Audit event schema",
"Retention",
"Phase 2 integration",
"Local-dev mode",
):
with self.subTest(heading=heading):
self.assertIn(heading, self.text)
def test_doc_names_every_role(self):
for role in console_authz.ROLE_ORDER:
with self.subTest(role=role):
self.assertIn(role, self.text)
def test_doc_names_every_console_action(self):
for action_id in console_authz.ACTIONS:
with self.subTest(action=action_id):
self.assertIn(action_id, self.text)
def test_doc_states_retention_defaults(self):
for days in console_audit.RETENTION_DAYS.values():
with self.subTest(days=days):
self.assertIn(str(days), self.text)
def test_doc_warns_local_dev_is_insecure(self):
self.assertIn("INSECURE", self.text.upper())
def test_doc_states_default_deny(self):
self.assertIn("deny", self.text.lower())
def test_doc_contains_no_secret_material(self):
self.assertEqual(scan_for_secrets(self.text), [])
def test_deployment_doc_links_to_the_model(self):
deployment = (DOCS / "webui-deployment.md").read_text(encoding="utf-8")
self.assertIn("webui-authz-audit", deployment)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+336 -31
View File
@@ -1,4 +1,4 @@
"""Tests for web UI project registry (#427)."""
"""Tests for web UI project registry (#427) and its API evolution (#635)."""
import json
import sys
import tempfile
@@ -11,57 +11,246 @@ from starlette.testclient import TestClient
from webui.app import create_app
from webui.project_registry import (
CURRENT_SCHEMA_VERSION,
REGISTRY_API_VERSION,
SUPPORTED_SCHEMA_VERSIONS,
RegistryError,
default_registry_path,
load_registry,
onboarding_summary,
project_to_dict,
)
from webui.registry_safety import is_forbidden_key
_REPO_ROOT = Path(__file__).resolve().parent.parent
_API_DOC = _REPO_ROOT / "docs" / "webui-project-registry-api.md"
class TestProjectRegistryLoader(unittest.TestCase):
def _valid_project(**overrides):
project = {
"id": "example",
"repo_name": "Example",
"gitea_owner": "Org",
"remote_host": "https://gitea.example.invalid",
"default_branch": "main",
"local_checkout_path": ".",
"profiles": {"author": "a", "reviewer": "r", "reconciler": "c"},
"workflow_paths": {"skill": "skills/x.md"},
}
project.update(overrides)
return project
def _write_registry(payload) -> Path:
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump(payload, handle)
return Path(handle.name)
class RegistryFileCase(unittest.TestCase):
"""Base class that cleans up temporary registry files."""
def setUp(self):
self._temp_paths: list[Path] = []
def tearDown(self):
for path in self._temp_paths:
path.unlink(missing_ok=True)
def write_registry(self, payload) -> Path:
path = _write_registry(payload)
self._temp_paths.append(path)
return path
class TestProjectRegistryLoader(RegistryFileCase):
def test_default_registry_loads_gitea_tools(self):
registry = load_registry()
self.assertEqual(registry.version, 1)
self.assertEqual(registry.version, CURRENT_SCHEMA_VERSION)
self.assertEqual(registry.schema_version, CURRENT_SCHEMA_VERSION)
self.assertEqual(registry.api_version, REGISTRY_API_VERSION)
self.assertEqual(len(registry.projects), 1)
project = registry.projects[0]
self.assertEqual(project.id, "gitea-tools")
self.assertEqual(project.repo_name, "Gitea-Tools")
self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
self.assertEqual(project.repo_full_name, "Scaled-Tech-Consulting/Gitea-Tools")
self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
self.assertEqual(project.remote_name, "prgs")
self.assertEqual(project.status, "active")
self.assertEqual(project.profiles["author"], "prgs-author")
self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
self.assertIn("skill", project.workflow_paths)
self.assertGreaterEqual(len(project.onboarding_checklist), 4)
def test_registry_rejects_credential_keys(self):
payload = {
def test_default_registry_onboarding_summary_is_complete(self):
summary = onboarding_summary(load_registry().projects[0])
self.assertEqual(summary.total, summary.complete)
self.assertEqual(summary.required_outstanding, 0)
self.assertTrue(summary.onboarding_complete)
def test_version_1_registry_still_loads_with_defaults(self):
path = self.write_registry({
"version": 1,
"projects": [
{
"id": "bad",
"repo_name": "Bad",
"gitea_owner": "Org",
"remote_host": "https://gitea.example.invalid",
"default_branch": "main",
"local_checkout_path": ".",
"profiles": {
"author": "a",
"reviewer": "r",
"reconciler": "c",
},
"workflow_paths": {"skill": "skills/x.md"},
"api_token": "secret",
}
_valid_project(
onboarding_checklist=[
{"id": "step", "title": "Step", "description": "Do it"}
]
)
],
}
})
registry = load_registry(path)
self.assertEqual(registry.schema_version, 1)
self.assertIn(1, SUPPORTED_SCHEMA_VERSIONS)
project = registry.projects[0]
self.assertEqual(project.status, "active")
self.assertIsNone(project.remote_name)
self.assertIsNone(project.last_seen_health)
step = project.onboarding_checklist[0]
self.assertEqual(step.state, "pending")
self.assertTrue(step.required)
self.assertFalse(onboarding_summary(project).onboarding_complete)
def test_onboarding_summary_counts_states(self):
path = self.write_registry({
"version": 2,
"projects": [
_valid_project(
onboarding_checklist=[
{"id": "a", "title": "A", "description": "d", "state": "complete"},
{"id": "b", "title": "B", "description": "d", "state": "blocked"},
{
"id": "c",
"title": "C",
"description": "d",
"state": "pending",
"required": False,
},
{
"id": "d",
"title": "D",
"description": "d",
"state": "not_applicable",
},
]
)
],
})
summary = onboarding_summary(load_registry(path).projects[0])
self.assertEqual(summary.total, 4)
self.assertEqual(summary.complete, 1)
self.assertEqual(summary.blocked, 1)
self.assertEqual(summary.pending, 1)
self.assertEqual(summary.not_applicable, 1)
# Only the blocked step is both required and outstanding.
self.assertEqual(summary.required_outstanding, 1)
self.assertFalse(summary.onboarding_complete)
def test_last_seen_health_is_parsed_when_present(self):
path = self.write_registry({
"version": 2,
"projects": [
_valid_project(
last_seen_health={
"status": "degraded",
"checked_at": "2026-01-01T00:00:00Z",
"detail": "daemon restart pending",
}
)
],
})
health = load_registry(path).projects[0].last_seen_health
self.assertIsNotNone(health)
self.assertEqual(health.status, "degraded")
self.assertEqual(health.checked_at, "2026-01-01T00:00:00Z")
def test_registry_rejects_credential_keys(self):
path = self.write_registry({
"version": 1,
"projects": [_valid_project(id="bad", api_token="redacted-placeholder")],
})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertIn("credential", ctx.exception.remediation.lower())
self.assertEqual(ctx.exception.field_path, "projects[0].api_token")
def test_unsupported_version_fails_closed_with_remediation(self):
path = self.write_registry({"version": 99, "projects": [_valid_project()]})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertIn("unsupported registry version", ctx.exception.message)
self.assertIn(str(CURRENT_SCHEMA_VERSION), ctx.exception.remediation)
self.assertEqual(ctx.exception.field_path, "version")
def test_missing_required_field_fails_closed(self):
broken = _valid_project()
del broken["default_branch"]
path = self.write_registry({"version": 2, "projects": [broken]})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertIn("default_branch", ctx.exception.message)
self.assertEqual(ctx.exception.field_path, "projects[0]")
def test_unknown_status_fails_closed(self):
path = self.write_registry({
"version": 2,
"projects": [_valid_project(status="mystery")],
})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertEqual(ctx.exception.field_path, "projects[0].status")
self.assertIn("active", ctx.exception.remediation)
def test_unknown_onboarding_state_fails_closed(self):
path = self.write_registry({
"version": 2,
"projects": [
_valid_project(
onboarding_checklist=[
{"id": "a", "title": "A", "description": "d", "state": "almost"}
]
)
],
})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertEqual(
ctx.exception.field_path,
"projects[0].onboarding_checklist[0].state",
)
def test_missing_profile_role_fails_closed(self):
path = self.write_registry({
"version": 2,
"projects": [_valid_project(profiles={"author": "a", "reviewer": "r"})],
})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertEqual(ctx.exception.field_path, "projects[0].profiles.reconciler")
def test_empty_projects_fails_closed(self):
path = self.write_registry({"version": 2, "projects": []})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertEqual(ctx.exception.field_path, "projects")
def test_invalid_json_fails_closed_with_location(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump(payload, handle)
handle.write("{not json")
path = Path(handle.name)
try:
with self.assertRaises(ValueError):
load_registry(path)
finally:
path.unlink(missing_ok=True)
self._temp_paths.append(path)
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertIn("not valid JSON", ctx.exception.message)
self.assertIn("line", ctx.exception.remediation)
def test_missing_file_fails_closed(self):
missing = Path(tempfile.gettempdir()) / "webui-registry-does-not-exist.json"
with self.assertRaises(RegistryError) as ctx:
load_registry(missing)
self.assertIn("could not be read", ctx.exception.message)
def test_default_registry_path_points_at_packaged_data(self):
path = default_registry_path()
@@ -81,31 +270,147 @@ class TestProjectRegistryRoutes(unittest.TestCase):
self.assertIn("prgs-author", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_projects_page_shows_status_and_progress(self):
response = self.client.get("/projects")
self.assertIn("Status", response.text)
self.assertIn("Onboarding", response.text)
self.assertIn("4/4 complete", response.text)
def test_project_detail_renders_checklist(self):
response = self.client.get("/projects/gitea-tools")
self.assertEqual(response.status_code, 200)
self.assertIn("Onboarding checklist", response.text)
self.assertIn("Configure execution profiles", response.text)
self.assertIn("branches/", response.text)
self.assertIn("Complete", response.text)
self.assertIn("required outstanding 0", response.text)
def test_project_detail_404(self):
response = self.client.get("/projects/unknown-repo")
self.assertEqual(response.status_code, 404)
def test_api_projects_json(self):
def test_api_projects_alias_stays_compatible(self):
response = self.client.get("/api/projects")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["version"], 1)
# #427 consumers keep these keys.
self.assertEqual(data["version"], CURRENT_SCHEMA_VERSION)
self.assertIn("source_path", data)
self.assertEqual(len(data["projects"]), 1)
self.assertEqual(data["projects"][0]["id"], "gitea-tools")
self.assertIn("onboarding_checklist", data["projects"][0])
def test_api_v1_projects_payload(self):
response = self.client.get("/api/v1/projects")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["api_version"], REGISTRY_API_VERSION)
self.assertEqual(data["schema_version"], CURRENT_SCHEMA_VERSION)
self.assertEqual(data["project_count"], 1)
self.assertEqual(data["source"]["kind"], "file")
self.assertTrue(data["source"]["inventory_complete"])
project = data["projects"][0]
self.assertEqual(project["status"], "active")
self.assertEqual(project["remote_name"], "prgs")
self.assertEqual(
project["repo_full_name"], "Scaled-Tech-Consulting/Gitea-Tools"
)
self.assertTrue(project["onboarding_summary"]["onboarding_complete"])
self.assertEqual(project["onboarding_checklist"][0]["state"], "complete")
self.assertIsNone(project["last_seen_health"])
def test_api_v1_project_detail(self):
response = self.client.get("/api/v1/projects/gitea-tools")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["api_version"], REGISTRY_API_VERSION)
self.assertEqual(data["project"]["id"], "gitea-tools")
self.assertEqual(data["source"]["kind"], "file")
def test_api_v1_project_detail_missing_fails_closed(self):
response = self.client.get("/api/v1/projects/not-registered")
self.assertEqual(response.status_code, 404)
data = response.json()
self.assertEqual(data["error"], "project_not_found")
self.assertEqual(data["project_id"], "not-registered")
self.assertIn("gitea-tools", data["known_project_ids"])
self.assertIn("remediation", data)
def test_api_v1_projects_is_read_only(self):
response = self.client.post("/api/v1/projects", json={})
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json()["error"], "read-only-mvp")
def test_project_to_dict_is_json_safe(self):
registry = load_registry()
encoded = json.dumps(project_to_dict(registry.projects[0]))
dto = project_to_dict(registry.projects[0])
encoded = json.dumps(dto)
self.assertIn("gitea-tools", encoded)
# Prose may mention tokens; no serialized *key* may look like a secret.
for key in dto:
with self.subTest(key=key):
self.assertFalse(is_forbidden_key(key))
class TestInvalidRegistryFailsClosedOverHttp(RegistryFileCase):
def setUp(self):
super().setUp()
self.path = self.write_registry({"version": 42, "projects": []})
self.client = TestClient(create_app())
def _with_bad_registry(self, url: str):
import os
from unittest import mock
with mock.patch.dict(
os.environ, {"WEBUI_PROJECT_REGISTRY": str(self.path)}, clear=False
):
return self.client.get(url)
def test_api_v1_reports_actionable_error(self):
response = self._with_bad_registry("/api/v1/projects")
self.assertEqual(response.status_code, 500)
data = response.json()
self.assertEqual(data["error"], "registry_invalid")
self.assertIn("unsupported registry version", data["detail"])
self.assertTrue(data["remediation"])
self.assertEqual(data["field_path"], "version")
def test_unversioned_alias_reports_actionable_error(self):
response = self._with_bad_registry("/api/projects")
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["error"], "registry_invalid")
def test_html_page_reports_actionable_error(self):
response = self._with_bad_registry("/projects")
self.assertEqual(response.status_code, 500)
self.assertIn("Project registry unavailable", response.text)
self.assertIn("Remediation", response.text)
class TestProjectRegistryApiDocs(unittest.TestCase):
def test_api_contract_is_documented(self):
self.assertTrue(_API_DOC.is_file(), f"missing {_API_DOC}")
text = _API_DOC.read_text(encoding="utf-8")
for token in (
"/api/v1/projects",
"/api/v1/projects/{project_id}",
"/api/projects",
"onboarding_summary",
"last_seen_health",
"registry_invalid",
"#635",
):
with self.subTest(token=token):
self.assertIn(token, text)
def test_route_table_lists_versioned_routes(self):
local_dev = (_REPO_ROOT / "docs" / "webui-local-dev.md").read_text(
encoding="utf-8"
)
self.assertIn("/api/v1/projects", local_dev)
self.assertIn("webui-project-registry-api.md", local_dev)
if __name__ == "__main__":
unittest.main()
unittest.main()
+499
View File
@@ -0,0 +1,499 @@
"""Tests for the read-only system-health API (#634).
Covers the acceptance criteria directly: a structured payload with readiness
and a dependency list (AC1), version and uptime when knowable (AC2), stale
runtime reported without a false mutation-safe claim (AC3), and the healthy /
degraded-dependency / redaction cases (AC4).
"""
import json
import os
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
import control_plane_db
from webui.app import create_app
from webui.deployment_boundary import scan_text_for_client_secrets
from webui.system_health import (
API_PATH,
STATUS_DEGRADED,
STATUS_DOWN,
STATUS_OK,
STATUS_SKIPPED,
DependencyProbe,
StaleRuntime,
assess_stale_runtime,
clear_probe_cache,
load_system_health,
namespace_summaries,
probe_control_plane_db,
probe_gitea,
process_uptime,
redact,
redact_url,
snapshot_to_dict,
)
def _probe(name, status, *, required=True, detail="detail", kind="test"):
return DependencyProbe(
name=name,
kind=kind,
status=status,
detail=detail,
required=required,
latency_ms=1.5,
metadata={},
)
_ALL_HEALTHY = (
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
_probe("gitea", STATUS_OK, required=False, kind="http"),
)
_CLEAN_PARITY = StaleRuntime(
daemon_head="abc123",
checkout_head="abc123",
remote_head="abc123",
stale=False,
determinable=True,
mutation_safe=True,
reasons=(),
)
class CleanParityMixin:
"""Pin parity for tests about aggregation rather than staleness.
Without this the assertions depend on the real checkout: a worktree whose
branch is ahead of its upstream is genuinely stale, which would degrade the
overall status and make these cases fail for an unrelated reason.
"""
def setUp(self):
super().setUp()
patcher = mock.patch(
"webui.system_health.assess_stale_runtime",
return_value=_CLEAN_PARITY,
)
patcher.start()
self.addCleanup(patcher.stop)
class TestDependencyAggregation(CleanParityMixin, unittest.TestCase):
"""AC1 — readiness and dependency list derived from probe results."""
def test_all_healthy_is_ok_and_ready(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
self.assertEqual(snapshot.status, STATUS_OK)
self.assertTrue(snapshot.ready)
self.assertTrue(snapshot.readiness_complete)
self.assertEqual(snapshot.readiness_reasons, ())
self.assertEqual(len(snapshot.dependencies), 3)
def test_required_dependency_down_blocks_readiness(self):
probes = (
_probe("control_plane_db", STATUS_DOWN, detail="file missing", kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
_probe("gitea", STATUS_OK, required=False, kind="http"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertEqual(snapshot.status, STATUS_DOWN)
self.assertFalse(snapshot.ready)
self.assertTrue(
any("control_plane_db" in reason for reason in snapshot.readiness_reasons)
)
def test_optional_dependency_down_degrades_but_stays_ready(self):
"""A failing optional probe must not claim the process itself is unready."""
probes = (
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
_probe("gitea", STATUS_DOWN, required=False, detail="timeout", kind="http"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertEqual(snapshot.status, STATUS_DEGRADED)
self.assertTrue(snapshot.ready)
self.assertTrue(any("gitea" in reason for reason in snapshot.readiness_reasons))
def test_unrun_required_probe_leaves_readiness_incomplete(self):
"""Not probed is not the same as passing."""
probes = (
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
_probe("repository", STATUS_SKIPPED, detail="offline", kind="git"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertFalse(snapshot.ready)
self.assertFalse(snapshot.readiness_complete)
self.assertEqual(snapshot.status, STATUS_DEGRADED)
def test_skipped_optional_probe_does_not_block_readiness(self):
probes = (
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
_probe("gitea", STATUS_SKIPPED, required=False, kind="http"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertTrue(snapshot.ready)
self.assertTrue(snapshot.readiness_complete)
class TestVersionAndUptime(CleanParityMixin, unittest.TestCase):
"""AC2 — version and uptime present when knowable."""
def test_uptime_and_start_time_present(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
self.assertGreaterEqual(snapshot.uptime_seconds, 0.0)
self.assertIn("T", snapshot.started_at)
def test_process_uptime_helper_matches_shape(self):
started_at, uptime = process_uptime()
self.assertIn("T", started_at)
self.assertGreaterEqual(uptime, 0.0)
def test_version_reports_python_and_schema_version(self):
probes = (
DependencyProbe(
name="control_plane_db",
kind="sqlite",
status=STATUS_OK,
detail="ok",
required=True,
latency_ms=1.0,
metadata={"schema_version": control_plane_db.SCHEMA_VERSION},
),
_probe("repository", STATUS_OK, kind="git"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertEqual(
snapshot.version.control_plane_schema_version,
control_plane_db.SCHEMA_VERSION,
)
self.assertTrue(snapshot.version.python_version)
def test_version_known_flag_false_when_sha_unavailable(self):
with mock.patch("webui.system_health._git", return_value=None):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
self.assertIsNone(snapshot.version.git_sha)
self.assertFalse(snapshot.version.known)
class TestStaleRuntime(unittest.TestCase):
"""AC3 — stale runtime reflected without a false mutation-safe claim."""
def test_matching_commits_are_mutation_safe(self):
assessment = assess_stale_runtime(
Path("/tmp"),
daemon_head="aaa",
git_reader=lambda *args: "aaa",
)
self.assertFalse(assessment.stale)
self.assertTrue(assessment.determinable)
self.assertTrue(assessment.mutation_safe)
def test_diverged_commits_are_stale_and_not_mutation_safe(self):
reads = {"HEAD": "aaa", "@{upstream}": "bbb"}
assessment = assess_stale_runtime(
Path("/tmp"),
daemon_head="aaa",
git_reader=lambda *args: reads.get(args[-1]),
)
self.assertTrue(assessment.stale)
self.assertFalse(assessment.mutation_safe)
self.assertTrue(assessment.reasons)
def test_unknown_remote_is_not_mutation_safe(self):
"""Indeterminate must never read as safe."""
reads = {"HEAD": "aaa", "@{upstream}": None}
assessment = assess_stale_runtime(
Path("/tmp"),
daemon_head="aaa",
git_reader=lambda *args: reads.get(args[-1]),
)
self.assertFalse(assessment.determinable)
self.assertFalse(assessment.mutation_safe)
self.assertFalse(assessment.stale)
self.assertTrue(
any("indeterminate" in reason for reason in assessment.reasons)
)
def test_unobservable_daemon_head_is_disclosed(self):
assessment = assess_stale_runtime(
Path("/tmp"),
git_reader=lambda *args: "aaa",
)
self.assertTrue(
any("not observable" in reason for reason in assessment.reasons)
)
def test_stale_runtime_degrades_overall_status(self):
reads = {"HEAD": "aaa", "@{upstream}": "bbb"}
# Pinned rather than inherited: this path uses the default git reader,
# so the assertion must hold whether or not the suite runs offline.
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": ""}), mock.patch(
"webui.system_health._git",
side_effect=lambda repo, *args: reads.get(args[-1]),
):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="aaa")
self.assertTrue(snapshot.stale_runtime.stale)
self.assertFalse(snapshot.stale_runtime.mutation_safe)
self.assertEqual(snapshot.status, STATUS_DEGRADED)
class TestControlPlaneDbProbe(unittest.TestCase):
"""The required local dependency, probed read-only."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.db_path = str(Path(self.tmp.name) / "control-plane.db")
def _build_db(self, schema_version):
conn = sqlite3.connect(self.db_path)
conn.execute("CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)")
conn.execute("CREATE TABLE leases (lease_id TEXT PRIMARY KEY, status TEXT)")
conn.execute(
"INSERT INTO schema_meta(key, value) VALUES ('schema_version', ?)",
(str(schema_version),),
)
conn.execute("INSERT INTO leases(lease_id, status) VALUES ('l1', 'active')")
conn.commit()
conn.close()
def test_missing_database_is_down(self):
probe = probe_control_plane_db(str(Path(self.tmp.name) / "absent.db"))
self.assertEqual(probe.status, STATUS_DOWN)
self.assertTrue(probe.required)
self.assertIsNotNone(probe.latency_ms)
def test_matching_schema_is_ok(self):
self._build_db(control_plane_db.SCHEMA_VERSION)
probe = probe_control_plane_db(self.db_path)
self.assertEqual(probe.status, STATUS_OK)
self.assertEqual(
probe.metadata["schema_version"], control_plane_db.SCHEMA_VERSION
)
self.assertEqual(probe.metadata["active_leases"], 1)
def test_mismatched_schema_is_degraded(self):
self._build_db(control_plane_db.SCHEMA_VERSION + 99)
probe = probe_control_plane_db(self.db_path)
self.assertEqual(probe.status, STATUS_DEGRADED)
def test_probe_does_not_create_a_database(self):
"""A health check must never initialise the substrate it inspects."""
absent = str(Path(self.tmp.name) / "never-created.db")
probe_control_plane_db(absent)
self.assertFalse(Path(absent).exists())
def test_unreadable_database_is_down_not_raised(self):
Path(self.db_path).write_text("this is not a sqlite database")
probe = probe_control_plane_db(self.db_path)
self.assertEqual(probe.status, STATUS_DOWN)
class TestRedaction(unittest.TestCase):
"""AC4 — redaction. No credential-shaped text crosses the boundary."""
def test_redacts_token_assignment(self):
cleaned = redact("failed with token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345")
self.assertNotIn("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", cleaned)
self.assertIn("[redacted]", cleaned)
def test_redacts_authorization_header_text(self):
cleaned = redact("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456")
self.assertNotIn("abcdefghijklmnopqrstuvwxyz123456", cleaned)
def test_redacts_long_opaque_strings(self):
cleaned = redact("value 0123456789abcdef0123456789abcdef here")
self.assertNotIn("0123456789abcdef0123456789abcdef", cleaned)
def test_url_userinfo_and_query_are_stripped(self):
cleaned = redact_url("https://user:[email protected]/api/v1?token=xyz")
self.assertNotIn("secretpass", cleaned)
self.assertNotIn("token=xyz", cleaned)
self.assertEqual(cleaned, "https://gitea.example.com/api/v1")
def test_url_inside_free_text_is_redacted(self):
cleaned = redact("GET https://u:[email protected]/x?token=abc failed")
self.assertNotIn("u:p@", cleaned)
self.assertNotIn("token=abc", cleaned)
def test_gitea_probe_failure_detail_is_redacted(self):
boom = RuntimeError(
"connection refused for https://user:[email protected]/api/v1/version"
)
with mock.patch("webui.system_health.get_auth_header", return_value="token x"), \
mock.patch("webui.system_health.api_request", side_effect=boom):
probe = probe_gitea("gitea.example.com")
self.assertEqual(probe.status, STATUS_DOWN)
self.assertNotIn("hunter2", probe.detail)
self.assertEqual(scan_text_for_client_secrets(probe.detail), [])
def test_credential_guard_refusal_is_a_status_not_a_crash(self):
with mock.patch(
"webui.system_health.get_auth_header",
side_effect=RuntimeError("daemon guard refused"),
):
probe = probe_gitea("gitea.example.com")
self.assertEqual(probe.status, STATUS_DEGRADED)
self.assertFalse(probe.required)
class TestNamespaceSummaries(unittest.TestCase):
"""A web process cannot prove IDE namespace health, and must not claim to."""
def test_every_namespace_reports_unproven(self):
rows = namespace_summaries()
self.assertTrue(rows)
for row in rows:
with self.subTest(namespace=row["namespace"]):
self.assertEqual(row["status"], "unproven")
self.assertFalse(row["ide_namespace_proven"])
self.assertIn("client_namespace", row["reason"])
class TestSystemHealthRoutes(CleanParityMixin, unittest.TestCase):
"""The HTTP surface: versioned path, status codes, read-only guard."""
def setUp(self):
super().setUp()
clear_probe_cache()
self.addCleanup(clear_probe_cache)
self.client = TestClient(create_app())
def _patch_snapshot(self, probes, daemon_head="abc123"):
snapshot = load_system_health(probes=probes, daemon_head=daemon_head)
patcher = mock.patch(
"webui.app.load_system_health",
return_value=snapshot,
)
patcher.start()
self.addCleanup(patcher.stop)
return snapshot
def test_versioned_route_is_registered(self):
self.assertEqual(API_PATH, "/api/v1/system/health")
self._patch_snapshot(_ALL_HEALTHY)
response = self.client.get(API_PATH)
self.assertEqual(response.status_code, 200)
def test_healthy_payload_shape(self):
self._patch_snapshot(_ALL_HEALTHY)
data = self.client.get(API_PATH).json()
self.assertEqual(data["status"], STATUS_OK)
self.assertTrue(data["readiness"]["ready"])
self.assertTrue(data["readiness"]["complete"])
self.assertEqual(data["api"], API_PATH)
self.assertEqual(len(data["dependencies"]), 3)
for key in ("version", "process", "stale_runtime", "mcp_namespaces"):
self.assertIn(key, data)
self.assertIn("uptime_seconds", data["process"])
self.assertIn("mutation_safe", data["stale_runtime"])
def test_degraded_dependency_returns_503(self):
probes = (
_probe("control_plane_db", STATUS_DOWN, detail="missing", kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
)
self._patch_snapshot(probes)
response = self.client.get(API_PATH)
self.assertEqual(response.status_code, 503)
data = response.json()
self.assertFalse(data["readiness"]["ready"])
self.assertTrue(data["readiness"]["reasons"])
def test_dependency_entries_expose_status_and_latency(self):
self._patch_snapshot(_ALL_HEALTHY)
data = self.client.get(API_PATH).json()
names = {entry["name"] for entry in data["dependencies"]}
self.assertEqual(names, {"control_plane_db", "repository", "gitea"})
for entry in data["dependencies"]:
with self.subTest(dependency=entry["name"]):
self.assertIn("status", entry)
self.assertIn("required", entry)
self.assertIn("latency_ms", entry)
def test_response_body_carries_no_client_secrets(self):
self._patch_snapshot(_ALL_HEALTHY)
body = self.client.get(API_PATH).text
self.assertEqual(scan_text_for_client_secrets(body), [])
def test_deep_flag_is_forwarded(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
with mock.patch(
"webui.app.load_system_health", return_value=snapshot
) as loader:
self.client.get(f"{API_PATH}?deep=1")
loader.assert_called_once_with(deep=True)
def test_shallow_is_the_default(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
with mock.patch(
"webui.app.load_system_health", return_value=snapshot
) as loader:
self.client.get(API_PATH)
loader.assert_called_once_with(deep=False)
def test_route_rejects_mutation_methods(self):
for method in ("POST", "PUT", "PATCH", "DELETE"):
with self.subTest(method=method):
response = self.client.request(method, API_PATH)
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json()["error"], "read-only-mvp")
def test_default_shallow_call_skips_the_network_probe(self):
"""The expensive probe must not run unless it was asked for."""
with mock.patch("webui.system_health.probe_gitea") as probe:
snapshot = load_system_health(deep=False)
probe.assert_not_called()
gitea = next(p for p in snapshot.dependencies if p.name == "gitea")
self.assertEqual(gitea.status, STATUS_SKIPPED)
class TestHealthRouteBackwardCompatibility(unittest.TestCase):
"""`/health` is expanded additively; MVP consumers must keep working."""
def setUp(self):
self.client = TestClient(create_app())
def test_mvp_keys_are_unchanged(self):
data = self.client.get("/health").json()
self.assertEqual(data["status"], "ok")
self.assertEqual(data["service"], "mcp-control-plane-webui")
self.assertEqual(data["mode"], "read-only-mvp")
self.assertIn("timestamp", data)
self.assertEqual(data["deployment"]["mode"], "internal-operator-console")
def test_health_points_at_the_versioned_api(self):
data = self.client.get("/health").json()
self.assertEqual(data["system_health_api"], API_PATH)
self.assertIn("uptime_seconds", data)
self.assertIn("started_at", data)
def test_health_runs_no_dependency_probe(self):
"""Liveness must stay cheap: no probe, no snapshot assembly."""
with mock.patch("webui.app.load_system_health") as loader:
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
loader.assert_not_called()
class TestSnapshotSerialisation(CleanParityMixin, unittest.TestCase):
def test_snapshot_dict_is_json_serialisable(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
encoded = json.dumps(snapshot_to_dict(snapshot))
self.assertIn("readiness", encoded)
if __name__ == "__main__":
unittest.main()
+458
View File
@@ -0,0 +1,458 @@
"""Tests for the worker registry and configuration schema (#798, epic #797)."""
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from webui.worker_registry import (
ALLOWED_ROLES,
SCHEMA_VERSION,
RegistryValidationError,
WorkerRegistry,
default_registry_path,
find_provider,
find_worker,
history_dir,
list_revisions,
load_registry,
registry_to_dict,
registry_to_document,
rollback_to_revision,
save_registry,
validate_payload,
worker_to_dict,
workers_for_provider,
)
_EXPECTED_PROVIDER_IDS = ("claude", "grok", "codex", "agy", "kimi-k")
def _provider(provider_id: str = "claude", **overrides) -> dict:
payload = {
"id": provider_id,
"display_name": "Claude",
"vendor": "Anthropic",
"executable": "claude",
"available": True,
"models": ["claude-opus-4-8"],
"notes": "",
}
payload.update(overrides)
return payload
def _worker(worker_id: str = "claude-author", **overrides) -> dict:
payload = {
"id": worker_id,
"display_name": "Claude author",
"provider": "claude",
"model": "claude-opus-4-8",
"project": "gitea-tools",
"role": "author",
"namespace": "gitea-author",
"profile": "prgs-author",
"workflow": "skills/llm-project-workflow/workflows/work-issue.md",
"schedule": {"kind": "cron", "expression": "0 * * * *"},
"timeout_seconds": 3600,
"enabled": True,
"scheduler": {"kind": "launchd", "label": "cc.prgs.claude.author"},
"notes": "",
}
payload.update(overrides)
return payload
def _document(providers=None, workers=None, **overrides) -> dict:
payload = {
"version": SCHEMA_VERSION,
"revision": 1,
"updated_at": "2026-07-22T00:00:00Z",
"providers": providers if providers is not None else [_provider()],
"workers": workers if workers is not None else [_worker()],
}
payload.update(overrides)
return payload
class _TempRegistryCase(unittest.TestCase):
"""Base case giving each test an isolated registry file."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.path = Path(self._tmp.name) / "workers.registry.json"
def write(self, document: dict) -> Path:
self.path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
return self.path
def parse(self, document: dict) -> WorkerRegistry:
return validate_payload(document, source_path=self.path)
class TestPackagedRegistry(unittest.TestCase):
"""AC: the declarative registry is the source of truth and ships with the app."""
def test_default_path_points_at_packaged_data(self):
path = default_registry_path()
self.assertEqual(path.name, "workers.registry.json")
self.assertEqual(path.parent.name, "data")
def test_packaged_registry_loads_and_validates(self):
registry = load_registry()
self.assertEqual(registry.version, SCHEMA_VERSION)
self.assertGreaterEqual(registry.revision, 1)
def test_packaged_registry_declares_all_five_providers(self):
registry = load_registry()
self.assertEqual(
tuple(provider.id for provider in registry.providers),
_EXPECTED_PROVIDER_IDS,
)
def test_packaged_registry_carries_no_credentials(self):
raw = default_registry_path().read_text(encoding="utf-8").lower()
for marker in ("token", "password", "secret", "api_key", "credential"):
self.assertNotIn(marker, raw)
class TestSeparateEntities(_TempRegistryCase):
"""AC: providers and configured workers are separate entities."""
def test_provider_may_exist_with_no_workers(self):
registry = self.parse(
_document(providers=[_provider("grok", display_name="Grok")], workers=[])
)
self.assertEqual(len(registry.providers), 1)
self.assertEqual(registry.workers, ())
self.assertEqual(workers_for_provider(registry, "grok"), ())
def test_many_workers_may_share_one_provider(self):
registry = self.parse(
_document(
workers=[
_worker("claude-author"),
_worker(
"claude-reviewer",
role="reviewer",
namespace="gitea-reviewer",
profile="prgs-reviewer",
scheduler={"kind": "launchd", "label": "cc.prgs.claude.reviewer"},
),
]
)
)
self.assertEqual(len(workers_for_provider(registry, "claude")), 2)
self.assertEqual(len(registry.providers), 1)
def test_worker_referencing_unknown_provider_is_refused(self):
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(workers=[_worker(provider="mystery")]))
self.assertIn("unknown provider", str(ctx.exception))
def test_lookup_helpers(self):
registry = self.parse(_document())
self.assertIsNotNone(find_worker(registry, "claude-author"))
self.assertIsNone(find_worker(registry, "absent"))
self.assertIsNotNone(find_provider(registry, "claude"))
self.assertIsNone(find_provider(registry, "absent"))
class TestRecordedFields(_TempRegistryCase):
"""AC: records provider, model, project, role, namespace/profile, workflow,
schedule, timeout, enabled state, and scheduler metadata."""
def test_every_required_field_is_recorded(self):
registry = self.parse(_document())
worker = registry.workers[0]
self.assertEqual(worker.provider, "claude")
self.assertEqual(worker.model, "claude-opus-4-8")
self.assertEqual(worker.project, "gitea-tools")
self.assertEqual(worker.role, "author")
self.assertEqual(worker.namespace, "gitea-author")
self.assertEqual(worker.profile, "prgs-author")
self.assertEqual(worker.workflow, "skills/llm-project-workflow/workflows/work-issue.md")
self.assertEqual(worker.schedule.kind, "cron")
self.assertEqual(worker.schedule.expression, "0 * * * *")
self.assertEqual(worker.timeout_seconds, 3600)
self.assertTrue(worker.enabled)
self.assertEqual(worker.scheduler.kind, "launchd")
self.assertEqual(worker.scheduler.label, "cc.prgs.claude.author")
def test_each_required_field_is_individually_required(self):
for field in (
"provider", "model", "project", "role", "namespace",
"profile", "workflow", "schedule", "timeout_seconds",
"enabled", "scheduler", "id", "display_name",
):
with self.subTest(field=field):
worker = _worker()
worker.pop(field)
with self.assertRaises(RegistryValidationError):
self.parse(_document(workers=[worker]))
def test_all_sanctioned_roles_are_accepted(self):
for role in ALLOWED_ROLES:
with self.subTest(role=role):
registry = self.parse(_document(workers=[_worker(role=role)]))
self.assertEqual(registry.workers[0].role, role)
def test_unsanctioned_role_is_refused(self):
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(workers=[_worker(role="admin")]))
self.assertIn("role must be one of", str(ctx.exception))
def test_worker_dict_round_trips_every_field(self):
registry = self.parse(_document())
encoded = worker_to_dict(registry.workers[0])
self.assertEqual(encoded, _worker())
json.dumps(encoded) # must stay JSON-safe for the #799 API
class TestSchemaValidation(_TempRegistryCase):
"""AC: supports schema validation — and fails closed."""
def test_unsupported_version_is_refused(self):
with self.assertRaises(RegistryValidationError):
self.parse(_document(version=2))
def test_root_must_be_an_object(self):
with self.assertRaises(RegistryValidationError):
validate_payload([], source_path=self.path)
def test_providers_must_be_non_empty(self):
with self.assertRaises(RegistryValidationError):
self.parse(_document(providers=[]))
def test_unknown_top_level_field_is_refused(self):
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(fleet=[]))
self.assertIn("unknown fields", str(ctx.exception))
def test_unknown_worker_field_is_refused_not_ignored(self):
# A typo'd field must not be silently dropped: "timeout_second" would
# otherwise read as "no timeout declared".
worker = _worker()
worker["timeout_second"] = 30
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(workers=[worker]))
self.assertIn("timeout_second", str(ctx.exception))
def test_credentials_are_refused_anywhere_in_the_document(self):
for label, mutate in (
("provider.api_token", lambda doc: doc["providers"][0].__setitem__("api_token", "x")),
("worker.password", lambda doc: doc["workers"][0].__setitem__("password", "x")),
("root.secret", lambda doc: doc.__setitem__("secret", "x")),
):
with self.subTest(field=label):
document = _document()
mutate(document)
with self.assertRaises(ValueError) as ctx:
self.parse(document)
self.assertIn("credential", str(ctx.exception).lower())
def test_duplicate_worker_id_is_refused(self):
workers = [_worker("dup"), _worker("dup", scheduler={"kind": "manual"})]
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(workers=workers))
self.assertIn("duplicate worker id", str(ctx.exception))
def test_duplicate_provider_id_is_refused(self):
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(providers=[_provider("claude"), _provider("claude")], workers=[]))
self.assertIn("duplicate provider id", str(ctx.exception))
def test_duplicate_launchagent_label_is_refused(self):
# Two workers sharing a label would silently overwrite each other's agent.
workers = [
_worker("a", scheduler={"kind": "launchd", "label": "cc.prgs.same"}),
_worker("b", scheduler={"kind": "launchd", "label": "cc.prgs.same"}),
]
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(workers=workers))
self.assertIn("duplicate scheduler label", str(ctx.exception))
def test_manual_scheduler_needs_no_label_and_many_may_coexist(self):
workers = [
_worker("a", scheduler={"kind": "manual"}),
_worker("b", scheduler={"kind": "manual"}),
]
registry = self.parse(_document(workers=workers))
self.assertEqual([w.scheduler.label for w in registry.workers], [None, None])
def test_launchd_scheduler_requires_a_label(self):
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(workers=[_worker(scheduler={"kind": "launchd"})]))
self.assertIn("label is required", str(ctx.exception))
def test_unknown_scheduler_kind_is_refused(self):
with self.assertRaises(RegistryValidationError):
self.parse(_document(workers=[_worker(scheduler={"kind": "systemd", "label": "x"})]))
def test_timeout_must_be_a_positive_bounded_integer(self):
for bad in (0, -1, "3600", 1.5, True, 86_401):
with self.subTest(timeout=bad):
with self.assertRaises(RegistryValidationError):
self.parse(_document(workers=[_worker(timeout_seconds=bad)]))
def test_enabled_must_be_a_real_boolean(self):
for bad in ("true", 1, None):
with self.subTest(enabled=bad):
with self.assertRaises(RegistryValidationError):
self.parse(_document(workers=[_worker(enabled=bad)]))
def test_identifier_shape_is_enforced(self):
for bad in ("Claude Author", "-leading", "UPPER", ""):
with self.subTest(worker_id=bad):
with self.assertRaises(RegistryValidationError):
self.parse(_document(workers=[_worker(bad)]))
class TestScheduleValidation(_TempRegistryCase):
"""Schedules are declarations; next-run computation belongs to #803."""
def test_interval_schedule_requires_positive_seconds(self):
registry = self.parse(
_document(workers=[_worker(schedule={"kind": "interval", "seconds": 900})])
)
self.assertEqual(registry.workers[0].schedule.seconds, 900)
with self.assertRaises(RegistryValidationError):
self.parse(_document(workers=[_worker(schedule={"kind": "interval"})]))
with self.assertRaises(RegistryValidationError):
self.parse(_document(workers=[_worker(schedule={"kind": "interval", "seconds": 0})]))
def test_cron_schedule_requires_five_fields(self):
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(workers=[_worker(schedule={"kind": "cron", "expression": "0 *"})]))
self.assertIn("five crontab fields", str(ctx.exception))
def test_manual_schedule_needs_no_timing(self):
registry = self.parse(_document(workers=[_worker(schedule={"kind": "manual"})]))
schedule = registry.workers[0].schedule
self.assertEqual(schedule.kind, "manual")
self.assertIsNone(schedule.seconds)
self.assertIsNone(schedule.expression)
def test_fields_from_the_wrong_kind_are_refused(self):
with self.assertRaises(RegistryValidationError) as ctx:
self.parse(_document(workers=[_worker(schedule={"kind": "manual", "seconds": 60})]))
self.assertIn("not valid for kind", str(ctx.exception))
def test_unknown_schedule_kind_is_refused(self):
with self.assertRaises(RegistryValidationError):
self.parse(_document(workers=[_worker(schedule={"kind": "hourly"})]))
class TestAtomicPersistence(_TempRegistryCase):
"""AC: atomic persistence."""
def test_save_then_load_round_trips(self):
registry = self.parse(_document())
save_registry(registry, self.path)
reloaded = load_registry(self.path)
self.assertEqual(
[worker_to_dict(w) for w in reloaded.workers],
[worker_to_dict(w) for w in registry.workers],
)
def test_save_leaves_no_temp_files_behind(self):
registry = self.parse(_document())
save_registry(registry, self.path)
save_registry(registry, self.path)
leftovers = [p.name for p in self.path.parent.iterdir() if p.name.startswith(".")]
self.assertEqual(leftovers, [])
def test_save_refuses_to_persist_an_invalid_document(self):
registry = self.parse(_document())
broken = WorkerRegistry(
version=registry.version,
revision=registry.revision,
updated_at=registry.updated_at,
providers=registry.providers,
# A worker whose provider is not declared in the registry.
workers=tuple(
type(worker)(**{**worker.__dict__, "provider": "vanished"})
for worker in registry.workers
),
source_path=self.path,
)
with self.assertRaises(RegistryValidationError):
save_registry(broken, self.path)
self.assertFalse(self.path.exists(), "invalid save must not create the file")
def test_document_shape_excludes_local_paths_but_api_shape_includes_it(self):
registry = self.parse(_document())
self.assertNotIn("source_path", registry_to_document(registry))
self.assertEqual(registry_to_dict(registry)["source_path"], str(self.path))
class TestVersioningAndRollback(_TempRegistryCase):
"""AC: versioning and rollback."""
def _seed(self) -> WorkerRegistry:
self.write(_document())
return load_registry(self.path)
def test_revision_increments_on_each_save(self):
registry = self._seed()
self.assertEqual(registry.revision, 1)
second = save_registry(registry, self.path)
self.assertEqual(second.revision, 2)
third = save_registry(second, self.path)
self.assertEqual(third.revision, 3)
def test_updated_at_is_refreshed_and_utc(self):
registry = self._seed()
saved = save_registry(registry, self.path)
self.assertRegex(saved.updated_at, r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
def test_superseded_revisions_are_retained(self):
registry = self._seed()
second = save_registry(registry, self.path)
save_registry(second, self.path)
self.assertEqual(list_revisions(self.path), (1, 2))
self.assertTrue(history_dir(self.path).is_dir())
def test_rollback_restores_prior_content_as_a_new_revision(self):
self.write(_document(workers=[_worker("original")]))
registry = load_registry(self.path)
changed = WorkerRegistry(
version=registry.version,
revision=registry.revision,
updated_at=registry.updated_at,
providers=registry.providers,
workers=(), # operator deletes every worker
source_path=self.path,
)
save_registry(changed, self.path)
self.assertEqual(load_registry(self.path).workers, ())
restored = rollback_to_revision(1, self.path)
self.assertEqual([w.id for w in restored.workers], ["original"])
# Append-only: the rollback publishes a new head rather than rewinding.
self.assertGreater(restored.revision, 2)
self.assertEqual([w.id for w in load_registry(self.path).workers], ["original"])
def test_rollback_to_unknown_revision_fails_closed(self):
self._seed()
with self.assertRaises(RegistryValidationError) as ctx:
rollback_to_revision(99, self.path)
self.assertIn("not retained", str(ctx.exception))
def test_revision_must_be_a_positive_integer(self):
for bad in (0, -1, "1", None):
with self.subTest(revision=bad):
with self.assertRaises(RegistryValidationError):
self.parse(_document(revision=bad))
def test_history_is_empty_before_any_save(self):
self.write(_document())
self.assertEqual(list_revisions(self.path), ())
if __name__ == "__main__":
unittest.main()
+274
View File
@@ -0,0 +1,274 @@
"""Hermetic tests for workflow dashboard (#605).
Covers terminal-blocked queue shapes in the spirit of #593/#592/#587 where an
active terminal-review lock must suppress other PRs as safe review/merge work.
"""
from __future__ import annotations
import unittest
from allocator_service import WorkCandidate
from workflow_dashboard import (
DASHBOARD_VERSION,
build_workflow_dashboard,
format_human_summary,
)
def _issue(
number: int,
*,
title: str = "",
labels: tuple[str, ...] = ("status:ready",),
priority: int = 20,
blocked: bool = False,
dependency_unmet: bool = False,
dependency_reason: str | None = None,
claimed: bool = False,
) -> WorkCandidate:
return WorkCandidate(
kind="issue",
number=number,
title=title or f"issue {number}",
labels=labels,
priority=priority,
blocked=blocked,
dependency_unmet=dependency_unmet,
dependency_reason=dependency_reason,
already_claimed_elsewhere=claimed,
)
def _pr(
number: int,
*,
title: str = "",
head_sha: str = "abc123",
request_changes: bool = False,
approved: bool = False,
mergeable: bool = False,
contaminated: bool = False,
approval_stale: bool = False,
priority: int = 5,
) -> WorkCandidate:
return WorkCandidate(
kind="pr",
number=number,
title=title or f"pr {number}",
head_sha=head_sha,
request_changes_current_head=request_changes,
approval_on_current_head=approved,
mergeable=mergeable,
approval_contaminated=contaminated,
approval_stale=approval_stale,
priority=priority,
)
class TestWorkflowDashboard(unittest.TestCase):
def test_version_and_read_only_payload(self):
snap = build_workflow_dashboard(
candidates=[_issue(605)],
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
payload = snap.as_dict()
self.assertTrue(payload["read_only"])
self.assertEqual(payload["dashboard_version"], DASHBOARD_VERSION)
self.assertTrue(payload["success"])
self.assertTrue(payload["inventory_complete"])
self.assertIn("human_summary", payload)
def test_never_marks_blocked_as_safe(self):
candidates = [
_issue(10, blocked=True, labels=("status:blocked",)),
_issue(11, dependency_unmet=True, dependency_reason="depends on #9"),
_issue(12, claimed=True),
_issue(605, labels=("status:ready",)),
]
snap = build_workflow_dashboard(candidates=candidates)
blocked_numbers = {e.number for e in snap.blocked_items}
self.assertIn(10, blocked_numbers)
self.assertIn(11, blocked_numbers)
self.assertIn(12, blocked_numbers)
for entry in snap.blocked_items:
self.assertFalse(entry.as_dict()["is_safe"])
self.assertEqual(entry.safe_for_roles, ())
self.assertIsNotNone(entry.block_reason)
author = snap.next_safe_by_role["author"]
self.assertEqual(author.status, "safe")
self.assertEqual(author.target_number, 605)
self.assertNotIn(author.target_number, blocked_numbers)
summary = format_human_summary(snap)
self.assertIn("NOT safe", summary)
self.assertIn("issue#10", summary.replace(" ", ""))
def test_author_prefers_oldest_ready_issue(self):
candidates = [
_issue(620, labels=("status:ready",)),
_issue(605, labels=("status:ready",)),
_issue(610, labels=("status:ready",)),
]
snap = build_workflow_dashboard(candidates=candidates)
author = snap.next_safe_by_role["author"]
self.assertEqual(author.status, "safe")
self.assertEqual(author.target_number, 605)
self.assertIn("gitea_allocate_next_work", author.prompt)
self.assertIn("role='author'", author.prompt)
def test_review_and_merge_ready_buckets(self):
candidates = [
_pr(100, head_sha="r1"), # review-ready
_pr(101, approved=True, mergeable=True, head_sha="m1", priority=8),
_pr(102, request_changes=True, head_sha="a1", priority=10),
]
snap = build_workflow_dashboard(candidates=candidates)
self.assertEqual([e.number for e in snap.review_ready_prs], [100])
self.assertEqual([e.number for e in snap.merge_ready_prs], [101])
self.assertEqual([e.number for e in snap.author_remediation], [102])
reviewer = snap.next_safe_by_role["reviewer"]
self.assertEqual(reviewer.status, "safe")
self.assertEqual(reviewer.target_number, 100)
self.assertEqual(reviewer.head_sha, "r1")
merger = snap.next_safe_by_role["merger"]
self.assertEqual(merger.status, "safe")
self.assertEqual(merger.target_number, 101)
self.assertEqual(merger.head_sha, "m1")
author = snap.next_safe_by_role["author"]
self.assertEqual(author.status, "safe")
self.assertEqual(author.target_number, 102)
def test_terminal_lock_blocks_other_prs_as_safe(
self,
):
"""#593/#592/#587-style: terminal lock ⇒ other PRs are not safe."""
candidates = [
_pr(587, head_sha="deadbeef", priority=5),
_pr(592, approved=True, mergeable=True, head_sha="cafebabe", priority=8),
_pr(593, head_sha="terminalhead", priority=9),
_issue(605, labels=("status:ready",)),
]
snap = build_workflow_dashboard(
candidates=candidates,
terminal_pr=593,
terminal_lock={"terminal_pr": 593, "active": True, "state": "locked"},
)
# Non-terminal PRs must appear blocked, never in safe buckets.
blocked_prs = {
e.number for e in snap.blocked_items if e.kind == "pr"
}
self.assertIn(587, blocked_prs)
self.assertIn(592, blocked_prs)
self.assertNotIn(593, blocked_prs) # terminal PR itself may still be routeable
# Terminal PR itself may remain review-ready; others must not.
self.assertEqual([e.number for e in snap.review_ready_prs], [593])
self.assertEqual(snap.merge_ready_prs, [])
self.assertNotIn(587, [e.number for e in snap.review_ready_prs])
self.assertNotIn(592, [e.number for e in snap.merge_ready_prs])
for entry in snap.blocked_items:
if entry.number in (587, 592):
self.assertIn("terminal-review lock", entry.block_reason or "")
self.assertEqual(entry.safe_for_roles, ())
self.assertFalse(entry.as_dict()["is_safe"])
reviewer = snap.next_safe_by_role["reviewer"]
# Reviewer may only target the terminal PR — never 587/592.
self.assertEqual(reviewer.status, "safe")
self.assertEqual(reviewer.target_number, 593)
self.assertEqual(reviewer.head_sha, "terminalhead")
self.assertNotEqual(reviewer.target_number, 587)
self.assertNotEqual(reviewer.target_number, 592)
merger = snap.next_safe_by_role["merger"]
# Merge-ready #592 is NOT safe while terminal lock is on #593.
self.assertNotEqual(merger.target_number, 592)
self.assertIn("593", merger.prompt)
self.assertIn(
merger.status,
("blocked_terminal", "idle", "safe"),
)
if merger.status == "safe":
self.assertEqual(merger.target_number, 593)
# Author issue work remains visible (issues are not terminal-blocked).
author = snap.next_safe_by_role["author"]
self.assertEqual(author.status, "safe")
self.assertEqual(author.target_number, 605)
summary = format_human_summary(snap)
self.assertIn("Terminal review lock: ACTIVE on PR #593", summary)
self.assertIn("Do not treat other open PRs as safe", summary)
def test_incomplete_inventory_fails_closed(self):
snap = build_workflow_dashboard(
candidates=[_issue(605)],
inventory_complete=False,
inventory_reasons=["page truncated"],
)
payload = snap.as_dict()
self.assertFalse(payload["inventory_complete"])
self.assertEqual(payload["review_ready_prs"], [])
self.assertEqual(payload["merge_ready_prs"], [])
for action in snap.next_safe_by_role.values():
self.assertEqual(action.status, "none")
self.assertIsNone(action.target_number)
self.assertIn("inventory incomplete", action.prompt.lower())
self.assertFalse(action.as_dict()["is_safe"])
def test_leases_partition_active_vs_stale(self):
leases = [
{"lease_id": "L1", "role": "author", "status": "active", "work_number": 605},
{"lease_id": "L2", "role": "reviewer", "status": "expired", "work_number": 99},
{"lease_id": "L3", "role": "merger", "stale": True, "work_number": 88},
]
snap = build_workflow_dashboard(candidates=[], leases=leases)
self.assertEqual(len(snap.active_leases_by_role["author"]), 1)
self.assertEqual(len(snap.stale_or_expired_leases), 2)
def test_discussion_and_controller_needed(self):
candidates = [
_issue(1, labels=("discussion", "type:discussion")),
_pr(2, contaminated=True, head_sha="x"),
]
snap = build_workflow_dashboard(candidates=candidates)
self.assertEqual([e.number for e in snap.discussion_issues], [1])
self.assertTrue(any(e.number == 2 for e in snap.controller_needed))
recon = snap.next_safe_by_role["reconciler"]
self.assertEqual(recon.status, "safe")
self.assertEqual(recon.target_number, 2)
def test_human_summary_includes_exact_prompts(self):
snap = build_workflow_dashboard(
candidates=[_issue(605)],
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
text = format_human_summary(snap)
self.assertIn("gitea_allocate_next_work", text)
self.assertIn("prgs/Scaled-Tech-Consulting/Gitea-Tools", text)
self.assertIn("never self-selects", text.lower())
self.assertIn("Primary next:", text)
def test_missing_pr_head_sha_is_blocked(self):
candidates = [_pr(50, head_sha="")]
# WorkCandidate allows empty head; dashboard must block it.
c = candidates[0]
c.head_sha = ""
snap = build_workflow_dashboard(candidates=[c])
self.assertEqual(len(snap.blocked_items), 1)
self.assertIn("head_sha", snap.blocked_items[0].block_reason or "")
self.assertEqual(snap.review_ready_prs, [])
if __name__ == "__main__":
unittest.main()
+230
View File
@@ -304,6 +304,193 @@ Who/what acts next:
)
)
# ── Stable control runtime states (#615) ─────────────────────────────────────
EXAMPLES.append(
_example(
"runtime_healthy",
"""
[CONTROLLER HANDOFF] Runtime check stable control runtime healthy
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- none
""",
f"""
[THREAD STATE LEDGER] Runtime stable control runtime healthy
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: runtime reported real_mutations_allowed=true
- Latest known validation: gitea_get_runtime_context read in this session
What changed:
- nothing; this is a read-only runtime observation
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: proceed with the allocated workflow phase
- Do not do: restart or relaunch the stable runtime
- Resume from: gitea_workflow_dashboard
""",
)
)
EXAMPLES.append(
_example(
"transport_flap_recovered",
"""
[CONTROLLER HANDOFF] Runtime check transport flap recovered
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- environment/tooling blocker: MCP transport dropped mid-session and recovered
""",
f"""
[THREAD STATE LEDGER] Runtime transport flap recovered, namespaces re-proven
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: all four namespaces re-proven after the flap
- Latest known validation: whoami + runtime context + capability resolve per namespace
What changed:
- author, reviewer, merger, and reconciler namespaces each re-proven independently
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: resume the interrupted workflow phase from its last durable state
- Do not do: treat author proof as proof of the other namespaces
- Resume from: the phase handoff that preceded the flap
""",
)
)
EXAMPLES.append(
_example(
"namespace_not_yet_reproven",
"""
[CONTROLLER HANDOFF] Runtime check reviewer namespace not re-proven
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- environment/tooling blocker: reviewer namespace not re-proven since the transport flap
""",
f"""
[THREAD STATE LEDGER] Runtime reviewer namespace not re-proven after flap
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: reviewer namespace unproven; mutation gate fails closed
- Latest known validation: author namespace re-proven; reviewer not attempted
What changed:
- reviewer mutations blocked with namespace_not_reproven_after_flap
What is blocked:
- Blocker classification: environment/tooling blocker
Who/what acts next:
- Next actor: reviewer
- Required action: run whoami, runtime context, and capability resolve in the reviewer namespace
- Do not do: substitute author proof for reviewer proof
- Resume from: docs/stable-runtime-promotion-runbook.md section 5
""",
)
)
EXAMPLES.append(
_example(
"promotion_completed",
"""
[CONTROLLER HANDOFF] Runtime promotion completed
Server-side mutation ledger:
- gitea_create_issue_comment on #615 with the promotion record
Blockers:
- none
""",
f"""
[THREAD STATE LEDGER] Runtime promotion completed and re-proven
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: server-side state changed
- Local verdict/state: promotion record carries every required field
- Latest known validation: assess_promotion_record valid=true; all namespaces re-proven
What changed:
- stable control runtime advanced to the promoted SHA and reloaded by the operator
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: resume normal workflow phases on the promoted runtime
- Do not do: promote again without a fresh record
- Resume from: docs/stable-runtime-promotion-runbook.md section 4
""",
)
)
EXAMPLES.append(
_example(
"rollback_required",
"""
[CONTROLLER HANDOFF] Runtime promotion rollback required
Server-side mutation ledger:
- gitea_create_issue_comment on #615 with the rollback evidence
Blockers:
- environment/tooling blocker: promoted runtime unhealthy, rollback required
""",
f"""
[THREAD STATE LEDGER] Runtime promoted runtime unhealthy, rollback required
What is true now:
- Runtime mode: unknown
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed after the promotion record
- Local verdict/state: promoted runtime failed namespace health; mutations blocked
- Latest known validation: namespace health probe reported EOF after reload
What changed:
- all PR/review/merge work stopped pending rollback to the previous runtime SHA
What is blocked:
- Blocker classification: environment/tooling blocker
Who/what acts next:
- Next actor: controller
- Required action: operator rolls back to the previous runtime SHA and re-proves every namespace
- Do not do: route around the unhealthy runtime or mutate from a dev/test runtime
- Resume from: docs/stable-runtime-promotion-runbook.md section 6
""",
)
)
EXAMPLES.append(
_example(
"duplicate_canonicalization_blocker",
@@ -336,6 +523,49 @@ Who/what acts next:
- Required action: implement #507 two-comment validator
- Do not do: recreate duplicate CTH issue
- Resume from: issue #507 body
""",
)
)
EXAMPLES.append(
_example(
"bound_worktree_missing_blocker",
"""
[CONTROLLER HANDOFF] Issue #618 — author mutation blocked
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- environment/tooling blocker: bound worktree missing; operator must recreate or repoint the worktree and reconnect
""",
"""
[THREAD STATE LEDGER] Issue #618 — author worktree binding unhealthy
What is true now:
- Issue state: open
- Server-side decision state: no server-side state changed
- Local verdict/state: author mutation tools fail closed consistently
- Latest known validation: runtime context reports workspace_healthy=false
- Role/profile: prgs-author
- Configured worktree path: branches/mcp-author-clean-ns (via GITEA_AUTHOR_WORKTREE)
- path_exists: false
- in_git_worktree_list: false
- inspected_git_root: null
What changed:
- nothing server-side; local env still points at a deleted role-bound worktree
What is blocked:
- Blocker classification: environment/tooling blocker
- Blocker detail: bound worktree missing; operator must recreate or repoint the worktree and reconnect
- create_issue and create_issue_comment (and other author mutations) agree: fail closed before API mutation
Who/what acts next:
- Next actor: operator
- Required action: recreate the worktree under branches/ (scripts/worktree-start or git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE to that path (or pass worktree_path), keep control checkout clean on master, reconnect the author MCP session, then re-run the mutation
- Do not do: retry mutations hoping create_issue_comment will still work while create_issue blocks; do not fall back to the control checkout or master
- Resume from: healthy author worktree binding + gitea_whoami + gitea_resolve_task_capability
""",
)
)
+186 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from starlette.applications import Starlette
@@ -11,14 +12,29 @@ from starlette.routing import Route
from webui.deployment_boundary import deployment_snapshot
from webui.layout import render_page
from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
from webui.project_registry import (
ProjectRegistry,
RegistryError,
find_project,
known_project_ids,
load_registry,
project_detail_to_dict,
registry_to_dict,
)
from webui.project_views import (
render_project_detail,
render_projects_list,
render_registry_error,
)
from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page
from final_report_validator import FINAL_REPORT_TASK_KINDS
from webui.gated_actions import attempt_action, load_action_registry, preview_action
from webui.gated_action_views import render_actions_page
from webui import console_audit
from webui.console_authz import authorize, rbac_matrix, resolve_principal
from webui.console_redaction import redaction_policy
from webui.audit_validator import audit_report, audit_to_dict
from webui.audit_views import render_audit_page
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
@@ -29,6 +45,12 @@ from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as wo
from webui.worktree_views import render_worktrees_page
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
from webui.runtime_views import render_runtime_page
from webui.system_health import (
API_PATH as SYSTEM_HEALTH_API_PATH,
load_system_health,
process_uptime,
snapshot_to_dict as system_health_to_dict,
)
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
@@ -62,16 +84,43 @@ async def home(_request: Request) -> HTMLResponse:
async def health(_request: Request) -> JSONResponse:
"""Liveness only — deliberately cheap, runs no dependency probe (#634).
Every MVP key is retained so existing pollers keep working; the additions
are a pointer to the structured API and the in-memory process uptime.
Readiness lives at that API because answering it costs real probes.
"""
bind_host = _request.app.state.webui_bind_host
started_at, uptime_seconds = process_uptime()
return JSONResponse({
"status": "ok",
"service": "mcp-control-plane-webui",
"mode": "read-only-mvp",
"timestamp": datetime.now(timezone.utc).isoformat(),
"deployment": deployment_snapshot(bind_host=bind_host),
"started_at": started_at,
"uptime_seconds": uptime_seconds,
"system_health_api": SYSTEM_HEALTH_API_PATH,
})
def _truthy_flag(value: str | None) -> bool:
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
async def api_system_health(request: Request) -> JSONResponse:
"""Structured read-only system health (#634).
`?deep=1` opts into the expensive network probe. The response status code
reflects readiness so automated checks can branch on it without parsing the
body: 200 when ready, 503 when a required dependency failed or never ran.
"""
deep = _truthy_flag(request.query_params.get("deep"))
snapshot = load_system_health(deep=deep)
payload = system_health_to_dict(snapshot)
return JSONResponse(payload, status_code=200 if snapshot.ready else 503)
async def queue(_request: Request) -> HTMLResponse:
snapshot = load_queue_snapshot()
return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot)))
@@ -81,14 +130,26 @@ async def api_queue(_request: Request) -> JSONResponse:
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
def _load_project_registry() -> tuple[ProjectRegistry | None, RegistryError | None]:
"""Load the registry, converting validation failure into a fail-closed pair."""
try:
return load_registry(), None
except RegistryError as exc:
return None, exc
async def projects(_request: Request) -> HTMLResponse:
registry = load_registry()
registry, error = _load_project_registry()
if error is not None:
return HTMLResponse(render_registry_error(error), status_code=500)
return HTMLResponse(render_projects_list(registry))
async def project_detail(request: Request) -> HTMLResponse:
project_id = request.path_params["project_id"]
registry = load_registry()
registry, error = _load_project_registry()
if error is not None:
return HTMLResponse(render_registry_error(error), status_code=500)
project = find_project(registry, project_id)
if project is None:
return HTMLResponse(
@@ -106,10 +167,47 @@ async def project_detail(request: Request) -> HTMLResponse:
async def api_projects(_request: Request) -> JSONResponse:
registry = load_registry()
"""Unversioned MVP alias, retained through Phase 1 (#632 section 6)."""
registry, error = _load_project_registry()
if error is not None:
return JSONResponse(error.to_dict(), status_code=500)
return JSONResponse(registry_to_dict(registry))
async def api_v1_projects(_request: Request) -> JSONResponse:
registry, error = _load_project_registry()
if error is not None:
return JSONResponse(error.to_dict(), status_code=500)
return JSONResponse(registry_to_dict(registry))
async def api_v1_project_detail(request: Request) -> JSONResponse:
project_id = request.path_params["project_id"]
registry, error = _load_project_registry()
if error is not None:
return JSONResponse(error.to_dict(), status_code=500)
project = find_project(registry, project_id)
if project is None:
return JSONResponse(
{
"error": "project_not_found",
"project_id": project_id,
"known_project_ids": known_project_ids(registry),
"remediation": (
"Request one of the known project ids, or add the project to the "
"registry file named in 'source'."
),
"source": {
"kind": "file",
"path": str(registry.source_path),
"inventory_complete": True,
},
},
status_code=404,
)
return JSONResponse(project_detail_to_dict(registry, project))
async def prompts(_request: Request) -> HTMLResponse:
return HTMLResponse(render_prompts_page())
@@ -215,6 +313,49 @@ async def api_actions(_request: Request) -> JSONResponse:
return JSONResponse(load_action_registry().to_dict())
def _request_id() -> str:
return f"req-{uuid.uuid4().hex}"
def _audit_target(action_id: str, params: dict[str, object]) -> dict[str, object]:
"""Describe the action target for the audit record (never secrets)."""
if "pr_number" in params:
return {"kind": "pr", "ref": f"#{params['pr_number']}"}
if "issue_number" in params:
return {"kind": "issue", "ref": f"#{params['issue_number']}"}
if "branch_name" in params:
return {"kind": "branch", "ref": str(params["branch_name"])}
return {"kind": "unspecified", "ref": action_id}
def _authorize_request(
request: Request,
action_id: str,
params: dict[str, object],
*,
for_execution: bool,
result: str,
) -> dict[str, object]:
"""Resolve principal, decide, and audit. Returns the decision payload.
Phase 1 records the decision rather than enforcing it as the terminal
outcome: ``webui.gated_actions`` already fails closed for every action, so
this layer cannot loosen anything. Phase 2 enforces on this same decision.
"""
principal = resolve_principal(headers=dict(request.headers))
decision = authorize(action_id, principal, for_execution=for_execution)
console_audit.record_event(
action_id=action_id,
result=result,
decision=decision,
principal=principal,
target=_audit_target(action_id, params),
request_id=_request_id(),
detail=decision.detail,
)
return decision.to_dict()
async def api_action_preview(request: Request) -> JSONResponse:
action_id = request.path_params["action_id"]
params = dict(request.query_params)
@@ -224,6 +365,13 @@ async def api_action_preview(request: Request) -> JSONResponse:
result = preview_action(action_id, **params)
if "error" in result:
return JSONResponse(result, status_code=404)
result["authorization"] = _authorize_request(
request,
action_id,
params,
for_execution=False,
result=console_audit.RESULT_PREVIEWED,
)
return JSONResponse(result)
@@ -237,10 +385,31 @@ async def api_action_attempt(request: Request) -> JSONResponse:
if not isinstance(body, dict):
body = {}
result = attempt_action(action_id, **body)
authorization = _authorize_request(
request,
action_id,
body,
for_execution=True,
result=(
console_audit.RESULT_DENIED
if not result.get("success")
else console_audit.RESULT_ALLOWED
),
)
result["authorization"] = authorization
status = 403 if not result.get("success") else 200
return JSONResponse(result, status_code=status)
async def api_console_security_model(_request: Request) -> JSONResponse:
"""Read-only publication of the #633 authorization/redaction/audit model."""
return JSONResponse({
"rbac": rbac_matrix(),
"redaction": redaction_policy(),
"audit": console_audit.audit_policy(),
})
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
path = request.url.path
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
@@ -263,11 +432,18 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
routes=[
Route("/", home, methods=["GET"]),
Route("/health", health, methods=["GET"]),
Route(SYSTEM_HEALTH_API_PATH, api_system_health, methods=["GET"]),
Route("/queue", queue, methods=["GET"]),
Route("/api/queue", api_queue, methods=["GET"]),
Route("/projects", projects, methods=["GET"]),
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
Route("/api/v1/projects", api_v1_projects, methods=["GET"]),
Route(
"/api/v1/projects/{project_id}",
api_v1_project_detail,
methods=["GET"],
),
Route("/prompts", prompts, methods=["GET"]),
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
Route("/api/prompts", api_prompts, methods=["GET"]),
@@ -291,6 +467,11 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
methods=["POST"],
),
Route("/api/leases", api_leases, methods=["GET"]),
Route(
"/api/console/security-model",
api_console_security_model,
methods=["GET"],
),
],
exception_handlers={405: method_not_allowed},
)
+281
View File
@@ -0,0 +1,281 @@
"""Console audit event schema, retention, and append-only sink (#633).
``gitea_audit`` records MCP-side *mutations*: which profile and Gitea user
performed which tool call. It carries no console actor, no identity source, no
correlation identifier, and no retention class, so it cannot answer the
question #633 exists to answer — *who sat at the console, what did they
attempt, and was it authorized?* An authorization denial is not a mutation and
would never appear there at all.
This module adds the console-side record. It does not replace ``gitea_audit``:
when a Phase 2 action eventually reaches MCP, both fire, correlated by
``correlation.request_id``.
Design constraints:
- **Redact before persist.** Every record passes through
``webui.console_redaction.redact_payload`` before serialization, so an
unredacted field is never durable.
- **Append-only.** Records are appended as JSON lines. Nothing here updates or
deletes; retention is metadata on each record, enforced by an operator-run
policy, never by silent rewriting.
- **Never raises.** Auditing must not break the request it describes. A failed
write returns ``False``.
- **Off by default.** With ``WEBUI_CONSOLE_AUDIT_LOG`` unset, events are still
*built* (so callers and tests see the schema) but nothing is written.
A record looks like this (synthetic values):
{"schema_version": 1, "event_id": "evt-0001",
"timestamp": "2026-07-22T10:16:42+00:00",
"actor": {"subject": "[email protected]", "role": "operator",
"identity_source": "access_proxy", "authenticated": true},
"action": "merge_pr", "action_class": "privileged",
"target": {"kind": "pr", "ref": "#123"},
"result": "denied", "reason_code": "insufficient_role",
"correlation": {"request_id": "req-abc", "session_id": null,
"mcp_task": "merge_pr", "mcp_permission": "gitea.pr.merge"},
"retention": {"class": "privileged", "days": 365,
"expires_at": "2027-07-22T10:16:42+00:00"},
"redacted": true}
Timestamps are timezone-aware ISO-8601 in UTC.
"""
from __future__ import annotations
import datetime
import json
import os
import uuid
from typing import Any
from webui import console_authz
from webui.console_redaction import redact_payload, scan_for_secrets
SCHEMA_VERSION = 1
AUDIT_LOG_ENV = "WEBUI_CONSOLE_AUDIT_LOG"
# Result vocabulary. ``denied`` is the one ``gitea_audit`` has no equivalent
# for: an authorization refusal never reaches the MCP layer.
RESULT_ALLOWED = "allowed"
RESULT_DENIED = "denied"
RESULT_PREVIEWED = "previewed"
RESULT_FAILED = "failed"
RESULT_SUCCEEDED = "succeeded"
RESULTS = frozenset(
{
RESULT_ALLOWED,
RESULT_DENIED,
RESULT_PREVIEWED,
RESULT_FAILED,
RESULT_SUCCEEDED,
}
)
# Retention classes and default lifetimes in days. Privileged and break-glass
# records outlive routine ones because they are what an incident review needs.
RETENTION_STANDARD = "standard"
RETENTION_PRIVILEGED = "privileged"
RETENTION_BREAK_GLASS = "break_glass"
RETENTION_DAYS: dict[str, int] = {
RETENTION_STANDARD: 90,
RETENTION_PRIVILEGED: 365,
RETENTION_BREAK_GLASS: 730,
}
# Fields every record must carry. Asserted by the test suite so a future edit
# cannot quietly drop one.
REQUIRED_FIELDS: tuple[str, ...] = (
"schema_version",
"event_id",
"timestamp",
"actor",
"action",
"action_class",
"target",
"result",
"reason_code",
"correlation",
"retention",
"redacted",
)
REQUIRED_ACTOR_FIELDS: tuple[str, ...] = (
"subject",
"role",
"identity_source",
"authenticated",
)
REQUIRED_CORRELATION_FIELDS: tuple[str, ...] = (
"request_id",
"session_id",
"mcp_task",
"mcp_permission",
)
def audit_log_path() -> str | None:
"""Configured sink path, or ``None`` when console auditing is off."""
return (os.environ.get(AUDIT_LOG_ENV) or "").strip() or None
def audit_enabled() -> bool:
return audit_log_path() is not None
def retention_class_for(action: console_authz.ConsoleAction | None) -> str:
"""Classify retention from the action, defaulting to the longest-lived.
An unknown action is treated as privileged rather than standard: for a
safety control the conservative direction is to keep the record longer.
"""
if action is None:
return RETENTION_PRIVILEGED
if action.break_glass:
return RETENTION_BREAK_GLASS
if action.privileged:
return RETENTION_PRIVILEGED
return RETENTION_STANDARD
def _retention_block(
retention_class: str, now: datetime.datetime
) -> dict[str, Any]:
days = RETENTION_DAYS.get(
retention_class, RETENTION_DAYS[RETENTION_PRIVILEGED]
)
return {
"class": retention_class,
"days": days,
"expires_at": (now + datetime.timedelta(days=days)).isoformat(),
}
def build_event(
*,
action_id: str,
result: str,
decision: console_authz.AuthorizationDecision | None = None,
principal: console_authz.Principal | None = None,
target: dict[str, Any] | None = None,
reason_code: str | None = None,
request_id: str | None = None,
session_id: str | None = None,
detail: str | None = None,
metadata: dict[str, Any] | None = None,
now: datetime.datetime | None = None,
event_id: str | None = None,
) -> dict[str, Any]:
"""Build one redacted, JSON-able console audit record.
Redaction runs here rather than at write time so an in-memory record handed
to a template or an API response is already clean.
"""
ts = now or datetime.datetime.now(datetime.timezone.utc)
action = console_authz.get_action(action_id)
who = principal or (
decision.principal if decision else console_authz.ANONYMOUS
)
resolved_result = result if result in RESULTS else RESULT_FAILED
resolved_reason = reason_code or (
decision.reason_code if decision else "unspecified"
)
retention_class = retention_class_for(action)
event: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"event_id": event_id or f"evt-{uuid.uuid4().hex}",
"timestamp": ts.isoformat(),
"actor": who.to_dict(),
"action": action_id,
"action_class": action.action_class if action else "unknown",
"target": dict(target or {}),
"result": resolved_result,
"reason_code": resolved_reason,
"correlation": {
"request_id": request_id,
"session_id": session_id,
"mcp_task": action.task_key if action else None,
"mcp_permission": action.mcp_permission if action else None,
},
"retention": _retention_block(retention_class, ts),
"redacted": True,
"detail": detail,
"metadata": dict(metadata or {}),
}
if decision is not None:
# Deliberately *not* named "authorization": ``gitea_audit`` treats that
# substring as a secret key hint (it matches the HTTP Authorization
# header) and would replace this whole block with the placeholder.
event["decision"] = {
"allowed": decision.allowed,
"required_role": decision.required_role,
"requires_confirmation": decision.requires_confirmation,
"dual_control": decision.dual_control,
"break_glass": decision.break_glass,
"execution_enabled": decision.execution_enabled,
}
redacted = redact_payload(event)
if not isinstance(redacted, dict): # pragma: no cover - defensive
return {"schema_version": SCHEMA_VERSION, "redacted": True}
return redacted
def write_event(event: dict[str, Any], path: str | None = None) -> bool:
"""Append *event* as one JSON line. Never raises.
Returns ``True`` when a line was written, ``False`` when auditing is off or
the write failed. A record that still trips a secret detector is dropped
rather than persisted.
"""
sink = path or audit_log_path()
if not sink:
return False
try:
if scan_for_secrets(event):
return False
line = json.dumps(event, default=str, sort_keys=True)
with open(sink, "a", encoding="utf-8") as handle:
handle.write(line + "\n")
return True
except Exception:
return False
def record_event(**kwargs: Any) -> dict[str, Any]:
"""Build and persist one record; return the record either way.
Callers get the record back so it can be surfaced in a response or a test
regardless of whether a sink is configured.
"""
event = build_event(**kwargs)
written = write_event(event)
return {"event": event, "written": written}
def audit_policy() -> dict[str, Any]:
"""Machine-readable audit schema and retention defaults (never secrets)."""
return {
"schema_version": SCHEMA_VERSION,
"required_fields": list(REQUIRED_FIELDS),
"required_actor_fields": list(REQUIRED_ACTOR_FIELDS),
"required_correlation_fields": list(REQUIRED_CORRELATION_FIELDS),
"results": sorted(RESULTS),
"retention_defaults_days": dict(RETENTION_DAYS),
"sink_env": AUDIT_LOG_ENV,
"enabled": audit_enabled(),
"append_only": True,
"redact_before_persist": True,
"timestamp_format": "ISO-8601, timezone-aware, UTC",
"relationship_to_mcp_audit": (
"webui.console_audit records console intent and authorization "
"outcomes; gitea_audit records MCP mutations. A Phase 2 action "
"emits both, correlated by correlation.request_id."
),
}
+537
View File
@@ -0,0 +1,537 @@
"""Console authorization and RBAC model (#633, Phase 1).
The read-only MVP (#426#436) ships with no authentication: protection comes
from network placement alone (#435). That is adequate while every route is a
GET, and inadequate the moment Phase 2 wires a gated write. This module is the
authorization model those writes must go through, landed *before* any of them
exists so no write can be added without an authority to check against.
Phase 1 scope is the model itself: identity resolution, the role matrix, the
privileged-action list, and a fail-closed :func:`authorize`. It deliberately
does **not** enable any write. ``webui.gated_actions`` stays globally disabled,
so an allow decision here is necessary but never sufficient.
Two invariants hold for every caller:
- **Default deny.** An unrecognised action, an unknown role, or an absent
principal denies. There is no implicit allow branch and no "unless" clause.
- **Authorization is not execution.** :func:`authorize` returns a decision
record. It never calls MCP, never mutates, and never consults credentials.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from typing import Any
from task_capability_map import required_permission, required_role
# --- Roles ------------------------------------------------------------------
# Ordered least to most authority. Higher ranks inherit every lower rank's
# permitted actions; the matrix below is expressed as a minimum required rank.
VIEWER = "viewer"
OPERATOR = "operator"
CONTROLLER = "controller"
ADMIN = "admin"
ROLE_ORDER: tuple[str, ...] = (VIEWER, OPERATOR, CONTROLLER, ADMIN)
_ROLE_RANK: dict[str, int] = {role: idx for idx, role in enumerate(ROLE_ORDER)}
ROLE_DESCRIPTIONS: dict[str, str] = {
VIEWER: "Read every console view. No write, ever, in any phase.",
OPERATOR: "Viewer, plus author-class work: claim, comment, open a PR.",
CONTROLLER: "Operator, plus reviewer/merger-class decisions on a PR.",
ADMIN: "Controller, plus destructive and policy-editing actions.",
}
# --- Identity sources -------------------------------------------------------
IDENTITY_NONE = "none"
IDENTITY_LOCAL_DEV = "local_dev"
IDENTITY_ACCESS_PROXY = "access_proxy"
IDENTITY_SOURCES: dict[str, dict[str, Any]] = {
IDENTITY_NONE: {
"description": (
"No authentication configured. Every request is anonymous and "
"capped at viewer. This is the MVP default and the only mode "
"whose safety rests entirely on network placement (#435)."
),
"authenticated": False,
"safe_for_shared_host": False,
"phase_available": 1,
},
IDENTITY_LOCAL_DEV: {
"description": (
"Developer-supplied principal read from the environment. INSECURE: "
"the subject and role are asserted, never verified. Loopback only."
),
"authenticated": True,
"safe_for_shared_host": False,
"phase_available": 1,
},
IDENTITY_ACCESS_PROXY: {
"description": (
"Subject asserted by a trusted access proxy (Cloudflare Access, "
"WARP, or an org VPN portal) via a verified request header. The "
"proxy performs authentication; the console performs authorization."
),
"authenticated": True,
"safe_for_shared_host": True,
"phase_available": 2,
},
}
# Environment configuration. All are read server-side and never rendered.
AUTH_MODE_ENV = "WEBUI_AUTH_MODE"
DEV_SUBJECT_ENV = "WEBUI_DEV_SUBJECT"
DEV_ROLE_ENV = "WEBUI_DEV_ROLE"
ROLE_MAP_ENV = "WEBUI_ROLE_MAP"
REQUIRE_PROBE_AUTH_ENV = "WEBUI_REQUIRE_PROBE_AUTH"
ACCESS_SUBJECT_HEADER = "cf-access-authenticated-user-email"
# --- Action classes ---------------------------------------------------------
CLASS_READ = "read"
CLASS_WRITE = "gated_write"
CLASS_PRIVILEGED = "privileged"
CLASS_DESTRUCTIVE = "destructive"
# --- Privileged action list -------------------------------------------------
# ``task_key`` ties each console action back to ``task_capability_map``, so the
# console cannot invent an authority the MCP layer does not already define.
@dataclass(frozen=True)
class ConsoleAction:
"""One console action and the authority required to invoke it."""
action_id: str
task_key: str
action_class: str
minimum_role: str
requires_confirmation: bool
dual_control: bool
break_glass: bool
phase: int
summary: str
@property
def mcp_permission(self) -> str:
return required_permission(self.task_key)
@property
def mcp_role(self) -> str:
return required_role(self.task_key)
@property
def privileged(self) -> bool:
return self.action_class in {CLASS_PRIVILEGED, CLASS_DESTRUCTIVE}
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["mcp_permission"] = self.mcp_permission
data["mcp_role"] = self.mcp_role
data["privileged"] = self.privileged
return data
_ACTION_SPECS: tuple[ConsoleAction, ...] = (
ConsoleAction(
action_id="claim_issue",
task_key="claim_issue",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Apply status:in-progress to an issue.",
),
ConsoleAction(
action_id="comment_issue",
task_key="comment_issue",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Post an issue comment.",
),
ConsoleAction(
action_id="create_issue",
task_key="create_issue",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Open a new tracking issue.",
),
ConsoleAction(
action_id="comment_pr",
task_key="comment_pr",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Post a PR thread comment.",
),
ConsoleAction(
action_id="create_pr",
task_key="create_pr",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Open a PR from a locked feature branch.",
),
ConsoleAction(
action_id="review_pr",
task_key="review_pr",
action_class=CLASS_PRIVILEGED,
minimum_role=CONTROLLER,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=3,
summary="Submit an approve / request-changes verdict.",
),
ConsoleAction(
action_id="close_pr",
task_key="close_pr",
action_class=CLASS_PRIVILEGED,
minimum_role=CONTROLLER,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=3,
summary="Close a pull request without merging.",
),
ConsoleAction(
action_id="merge_pr",
task_key="merge_pr",
action_class=CLASS_PRIVILEGED,
minimum_role=CONTROLLER,
requires_confirmation=True,
dual_control=True,
break_glass=True,
phase=3,
summary="Merge an approved pull request.",
),
ConsoleAction(
action_id="delete_branch",
task_key="delete_branch",
action_class=CLASS_DESTRUCTIVE,
minimum_role=ADMIN,
requires_confirmation=True,
dual_control=True,
break_glass=True,
phase=3,
summary="Remove a remote feature branch.",
),
)
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
def privileged_actions() -> tuple[ConsoleAction, ...]:
"""Actions requiring dual control, break-glass, or controller+ authority."""
return tuple(a for a in _ACTION_SPECS if a.privileged)
def get_action(action_id: str) -> ConsoleAction | None:
return ACTIONS.get(action_id)
# --- Principals -------------------------------------------------------------
@dataclass(frozen=True)
class Principal:
"""Who is making a request, and how strongly that is known."""
subject: str
role: str
identity_source: str
authenticated: bool
warnings: tuple[str, ...] = field(default_factory=tuple)
@property
def rank(self) -> int:
return _ROLE_RANK.get(self.role, -1)
def to_dict(self) -> dict[str, Any]:
return {
"subject": self.subject,
"role": self.role,
"identity_source": self.identity_source,
"authenticated": self.authenticated,
"warnings": list(self.warnings),
}
ANONYMOUS = Principal(
subject="anonymous",
role=VIEWER,
identity_source=IDENTITY_NONE,
authenticated=False,
warnings=("No authentication configured; capped at viewer.",),
)
def auth_mode(env: dict[str, str] | None = None) -> str:
"""Resolve the configured identity source, defaulting to ``none``."""
source = env if env is not None else os.environ
raw = (source.get(AUTH_MODE_ENV) or "").strip().lower().replace("-", "_")
if raw in IDENTITY_SOURCES:
return raw
return IDENTITY_NONE
def _role_map(env: dict[str, str]) -> dict[str, str]:
"""Parse ``WEBUI_ROLE_MAP`` (JSON subject→role). Invalid config yields {}."""
raw = (env.get(ROLE_MAP_ENV) or "").strip()
if not raw:
return {}
try:
parsed = json.loads(raw)
except Exception:
return {}
if not isinstance(parsed, dict):
return {}
return {
str(k): str(v).strip().lower()
for k, v in parsed.items()
if str(v).strip().lower() in _ROLE_RANK
}
def resolve_principal(
headers: dict[str, str] | None = None,
env: dict[str, str] | None = None,
) -> Principal:
"""Resolve the requesting principal. Unknown or unconfigured → anonymous.
Never raises and never trusts a client-supplied role: the role always comes
from server-side configuration keyed by the resolved subject.
"""
source_env = dict(env) if env is not None else dict(os.environ)
lowered = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
mode = auth_mode(source_env)
if mode == IDENTITY_LOCAL_DEV:
subject = (source_env.get(DEV_SUBJECT_ENV) or "").strip()
if not subject:
return ANONYMOUS
role = (source_env.get(DEV_ROLE_ENV) or VIEWER).strip().lower()
if role not in _ROLE_RANK:
role = VIEWER
return Principal(
subject=subject,
role=role,
identity_source=IDENTITY_LOCAL_DEV,
authenticated=True,
warnings=(
"local-dev identity is asserted, not verified; never use "
"outside loopback.",
),
)
if mode == IDENTITY_ACCESS_PROXY:
subject = (lowered.get(ACCESS_SUBJECT_HEADER) or "").strip()
if not subject:
# Proxy mode with no proxy header means the request did not
# traverse the proxy. Fail closed rather than trust it.
return ANONYMOUS
role = _role_map(source_env).get(subject, VIEWER)
return Principal(
subject=subject,
role=role,
identity_source=IDENTITY_ACCESS_PROXY,
authenticated=True,
)
return ANONYMOUS
def probe_auth_required(env: dict[str, str] | None = None) -> bool:
"""Whether non-public probes must be authenticated. Default False.
#633 requires the console to *fail closed on missing auth for non-public
health probes if configured*. The default stays off so the MVP ``/health``
contract is unchanged; an operator opts in explicitly.
"""
source = env if env is not None else os.environ
return (source.get(REQUIRE_PROBE_AUTH_ENV) or "").strip().lower() in {
"1",
"true",
"yes",
}
# --- Authorization ----------------------------------------------------------
DENY_UNKNOWN_ACTION = "unknown_action"
DENY_UNAUTHENTICATED = "unauthenticated"
DENY_INSUFFICIENT_ROLE = "insufficient_role"
DENY_UNKNOWN_ROLE = "unknown_role"
DENY_PHASE_NOT_ACTIVE = "phase_not_active"
ALLOW_PREVIEW = "allowed_preview_only"
# Phase 1 is the only active console phase. Phase 2 opens gated writes and is
# gated on this model landing; nothing here enables it.
ACTIVE_PHASE = 1
@dataclass(frozen=True)
class AuthorizationDecision:
"""Result of an authorization check. Never an execution grant."""
allowed: bool
reason_code: str
detail: str
action_id: str
principal: Principal
required_role: str | None = None
action_class: str | None = None
requires_confirmation: bool = False
dual_control: bool = False
break_glass: bool = False
execution_enabled: bool = False
def to_dict(self) -> dict[str, Any]:
return {
"allowed": self.allowed,
"reason_code": self.reason_code,
"detail": self.detail,
"action_id": self.action_id,
"principal": self.principal.to_dict(),
"required_role": self.required_role,
"action_class": self.action_class,
"requires_confirmation": self.requires_confirmation,
"dual_control": self.dual_control,
"break_glass": self.break_glass,
"execution_enabled": self.execution_enabled,
"active_phase": ACTIVE_PHASE,
}
def authorize(
action_id: str,
principal: Principal | None = None,
*,
for_execution: bool = False,
) -> AuthorizationDecision:
"""Decide whether *principal* may invoke *action_id*. Deny by default.
``for_execution`` distinguishes a read-only preview from a real invocation.
Even an allowed decision reports ``execution_enabled=False`` while the
console is in Phase 1, so no caller can read an allow as permission to
mutate.
"""
who = principal if principal is not None else ANONYMOUS
action = get_action(action_id)
if action is None:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_UNKNOWN_ACTION,
detail=f"No console action registered as {action_id!r}.",
action_id=action_id,
principal=who,
)
base: dict[str, Any] = {
"action_id": action_id,
"principal": who,
"required_role": action.minimum_role,
"action_class": action.action_class,
"requires_confirmation": action.requires_confirmation,
"dual_control": action.dual_control,
"break_glass": action.break_glass,
"execution_enabled": False,
}
if not who.authenticated:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_UNAUTHENTICATED,
detail=(
"Write actions require an authenticated principal; this "
"request is anonymous."
),
**base,
)
if who.rank < 0:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_UNKNOWN_ROLE,
detail=f"Role {who.role!r} is not in the console role matrix.",
**base,
)
if who.rank < _ROLE_RANK[action.minimum_role]:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_INSUFFICIENT_ROLE,
detail=(
f"Action {action_id!r} requires {action.minimum_role!r}; "
f"principal holds {who.role!r}."
),
**base,
)
if for_execution and action.phase > ACTIVE_PHASE:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_PHASE_NOT_ACTIVE,
detail=(
f"Action {action_id!r} belongs to phase {action.phase}; the "
f"console is in phase {ACTIVE_PHASE}. Execution is not wired."
),
**base,
)
return AuthorizationDecision(
allowed=True,
reason_code=ALLOW_PREVIEW,
detail=(
"Principal holds the required role. Preview only — execution "
"remains disabled until the Phase 2 action framework ships."
),
**base,
)
def rbac_matrix() -> dict[str, Any]:
"""Machine-readable RBAC matrix and privileged-action list."""
return {
"model_version": 1,
"active_phase": ACTIVE_PHASE,
"roles": [
{
"role": role,
"rank": _ROLE_RANK[role],
"description": ROLE_DESCRIPTIONS[role],
"permitted_actions": sorted(
a.action_id
for a in _ACTION_SPECS
if _ROLE_RANK[role] >= _ROLE_RANK[a.minimum_role]
),
}
for role in ROLE_ORDER
],
"identity_sources": IDENTITY_SOURCES,
"actions": [a.to_dict() for a in _ACTION_SPECS],
"privileged_actions": [a.action_id for a in privileged_actions()],
"default_decision": "deny",
"execution_enabled": False,
}

Some files were not shown because too many files have changed in this diff Show More