Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ Full rationale in `docs/DESIGN.md`.
- **Cycle detection covers both chains**: `CircularImportError` for the import chain, `CircularImplementationError` for the implementation chain.
- **FK constraints scope evidence from implementation children**: SVCs/MVRs/annotations referencing out-of-scope requirements are rejected by SQLite FK checks on insert — no explicit filtering needed.
- **Test results need explicit scoping**: no FK (keyed by FQN), so a scope check is required when inserting test results from implementation children.
- **Long-lived servers must not bind the repository at startup**: `ProjectSession` reloads itself (`ensure_fresh()`, driven by the input fingerprint in `common/snapshot_fingerprint.py`), so an MCP tool that captured `session.repo` in its closure would keep serving the pre-reload database. Resolve the repository per request via the server's `_repo()` helper. A snapshot that cannot be reloaded is an error, never a fallback to the superseded data — that silent staleness was bug #437.
- **The requirement "complete" verdict has ONE source of truth**: `StatisticsService`'s per-requirement computation (producing `RequirementStatus`). Every consumer — `status`/`report`/`export`, the MCP tools (`get_status`, `get_requirement_status`, `get_requirements_status`), and LSP — MUST derive completeness from that same computation. Do NOT re-implement "is this requirement met / are its automated tests satisfied" anywhere else (e.g. a parallel helper in `common/queries/details.py`). Two parallel verdict paths silently drifted and caused bug #411 (consolidation tracked in #412); a private re-derivation is the regression to guard against. When the verdict logic must be reused, extract/call the shared predicate — never copy its traversal.

## Key Conventions
Expand Down
37 changes: 35 additions & 2 deletions docs/modules/ROOT/pages/mcp.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ reqstool mcp local -p /path/to/reqstool
----

The server starts and blocks, serving MCP requests over stdio. The project at the given path is
loaded once at startup.
loaded at startup and reloaded whenever its files change — see <<snapshot-freshness>>.

=== Other sources

Expand Down Expand Up @@ -164,7 +164,17 @@ Overall traceability status across all requirements — completion counts, test

*Parameters:* none

*Returns:* status summary dict
*Returns:* status summary dict, plus a `snapshot` field describing the data it was computed
from: `{ built_at, reload, tracked_files, warnings }`. See <<snapshot-freshness>>.

==== `refresh`

Forces an immediate reload of the project from disk. Reloading is automatic when input files
change, so this is only needed to reload unconditionally or to confirm what is being served.

*Parameters:* none

*Returns:* `{ built_at, reload, tracked_files, warnings }`

==== `get_requirement_status`

Expand Down Expand Up @@ -210,6 +220,29 @@ in_progress = [
]
----

[[snapshot-freshness]]
== Snapshot Freshness

An MCP server is typically spawned by an AI harness and left running for days, while builds
regenerate `annotations.yml` and JUnit XML underneath it. The server therefore records which
local files it parsed — including files it looked for and did not find, and the concrete files
each `test_results` pattern matched — and re-checks them before answering each request. If
anything changed, the project is reloaded first.

Consequences worth knowing:

* *Files that appear later count as changes.* A server started before the first build picks up
the `annotations.yml` that build produces; it does not keep reporting zero implementations.
* *A project that no longer parses is an error, not a stale answer.* If the sources changed but
the new state cannot be loaded — a half-written YAML file, say — tools fail with the parse
error rather than answering from the superseded snapshot. Fixing the file restores service on
the next call.
* *Absent build artifacts are reported, not counted as zero.* When a configured `test_results`
pattern matches no files, `get_status` says so under `snapshot.warnings` instead of silently
reporting a project with no tests.
* *Only local sources are watched.* Remote sources (git, maven, npm, pypi) are version-pinned
downloads, so they are never treated as stale. Use `refresh` to reload those.

== reqstool-ai

https://github.com/reqstool/reqstool-ai[reqstool-ai] provides a marketplace of AI agents,
Expand Down
18 changes: 18 additions & 0 deletions docs/reqstool/requirements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,24 @@ requirements:
description: The system shall report an identical completion verdict and output structure for a given requirement across the status CLI command and the MCP get_requirement_status and get_requirements_status tools, in both build-only and post-build scoping modes, derived from a single shared verdict computation and a single shared serializer.
categories: ["functional-suitability"]
revision: "0.11.0"
- id: MCP_0006
title: MCP snapshot freshness
significance: shall
description: The system shall answer MCP queries from a snapshot that reflects the current state of the served project's local input files, reloading them automatically when they change and on explicit request, so that a long-lived server does not answer from the snapshot captured when it started.
categories: ["functional-suitability"]
revision: "0.11.0"
- id: MCP_0007
title: MCP snapshot reload failure reporting
significance: shall
description: The system shall report an error when the served project's input files have changed but the new state cannot be loaded, and shall not answer from the superseded snapshot.
categories: ["reliability"]
revision: "0.11.0"
- id: MCP_0008
title: MCP snapshot provenance and missing artifact reporting
significance: shall
description: The system shall report when the served snapshot was parsed, and shall explicitly report configured test result patterns that match no files, so that absent build artifacts are distinguishable from an absence of tests.
categories: ["interaction-capability"]
revision: "0.11.0"

# --- data-sources capability (derived from openspec/specs/data-sources) ---
- id: SOURCE_0001
Expand Down
18 changes: 18 additions & 0 deletions docs/reqstool/software_verification_cases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,24 @@ cases:
description: "GIVEN the same dataset WHEN get_requirement_status and get_requirements_status are called THEN they report the same completed verdict and output structure as the status command for the same requirement, in both build-only and post-build modes"
verification: automated-test
revision: "0.11.0"
- id: SVC_MCP_0006
requirement_ids: ["MCP_0006"]
title: "MCP server follows changes to the served project"
description: "GIVEN a running MCP server WHEN the served project's requirements, annotations or test results are changed, added or removed THEN subsequent tool calls answer from the changed files without a restart, and WHEN nothing changed THEN the project is not reparsed"
verification: automated-test
revision: "0.11.0"
- id: SVC_MCP_0007
requirement_ids: ["MCP_0007"]
title: "MCP server reports a failed reload"
description: "GIVEN a running MCP server WHEN the served project has changed but no longer parses THEN tool calls report the load error instead of answering from the previous snapshot"
verification: automated-test
revision: "0.11.0"
- id: SVC_MCP_0008
requirement_ids: ["MCP_0008"]
title: "MCP server reports snapshot provenance and absent artifacts"
description: "GIVEN a running MCP server WHEN get_status is called THEN it reports when its data was parsed, and WHEN a configured test results pattern matches no files THEN that is reported as a warning rather than as zero tests"
verification: automated-test
revision: "0.11.0"

# --- data-sources ---
- id: SVC_SOURCE_0001
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/mcp-snapshot-freshness/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-15
56 changes: 56 additions & 0 deletions openspec/changes/mcp-snapshot-freshness/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Design

## Why not re-parse per request

Issue #437 suggests re-reading sources and artifacts on every request. That is correct but
expensive in the wrong place: a rebuild walks the whole import chain, including remote imports,
which means network fetches for git/maven/pypi sources on every tool call.

Stat-then-rebuild gives the same freshness guarantee for local sources at the cost of a few dozen
`stat()` calls and one `rglob` per configured test-result pattern, and remote imports are
version-pinned anyway. The check runs unconditionally per request — no debounce interval — because
a debounce window is exactly the "answered from just before the build" case this change exists to
eliminate.

## What is fingerprinted

Per parsed source, when its location is local:

| Tracked | Why |
|---|---|
| `requirements.yml`, `software_verification_cases.yml`, `manual_verification_results.yml`, `annotations.yml` | The parsed inputs. Stamped whether or not they exist, so a file the build generates later registers as a change. |
| `reqstool_config.yml` | Decides which files and patterns are read at all. |
| each `test_results` pattern + the files it matched | Test results are resolved from globs; a new JUnit XML under a matched pattern has to count. Each matched file is stamped, so a rewritten report counts too. |

Stamps compare `(exists, st_mtime_ns, st_size)`. Content hashing was not used: it buys protection
against a rewrite that preserves both mtime and size, which no build produces, at the cost of
reading every file on every request.

Paths are recorded under the **real project directory**, not the temp tree. A local location is
materialized as a symlink into a `TemporaryDirectory` owned by the generator, and that directory is
gone by the time staleness is checked.

## Failure handling

`build()` failing leaves the session not ready and the database closed — deliberately, since the
alternative is serving data known to be superseded. Two details make that survivable:

- The failed build re-stamps the previous fingerprint against current disk. A tree that does not
parse is therefore parsed once, not once per request, and the next edit makes it stale again.
- `ensure_fresh()` distinguishes the two states in its message: *sources changed but reloading them
failed* (this call triggered the rebuild) versus *project is not loaded* (an earlier reload failed
and nothing has changed since).

## Concurrency

`ensure_fresh()` can replace the database underneath a request handler, so `ProjectSession` guards
build/close/ensure_fresh with an `RLock`. MCP tools are async and run on the event loop thread,
which is also the thread the SQLite connection is created on, so the rebuild happens on the
connection's own thread — a rebuild blocks the loop for its duration, as the startup build already
does.

## Where the verdict comes from

`get_status` continues to derive every number from `StatisticsService`. The `snapshot` field
describes the *inputs*, never the verdict, so this change adds no second opinion about whether a
requirement is complete (see MCP_0005 and CLAUDE.md).
66 changes: 66 additions & 0 deletions openspec/changes/mcp-snapshot-freshness/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Why

`reqstool mcp` parses the project once at startup and then serves that snapshot for as long as
the process lives. AI harnesses spawn the server and keep it up for days, while builds regenerate
`annotations.yml` and JUnit XML underneath it. Issue #437 reports the consequence: a server
spawned days earlier answered `get_status` with 14 requirements, 0 implementations, 0 tests, while
the CLI against the same tree reported 55/55 complete. The stale answer is well-formed, so neither
an agent nor a human can tell it apart from a real result.

Two things cause it. The server binds the repository into its tool closures at startup, so nothing
short of a restart can change what the tools read. And nothing records which files were parsed, so
there is no way to notice that the tree moved on — in particular "the `test_results` pattern
matched no files" is indistinguishable from "this project has no tests".

## What Changes

- Capture a **fingerprint** of the local input files each parse read: the four data YAMLs plus
`reqstool_config.yml`, stamped whether or not they exist, and each `test_results` glob pattern
together with the concrete files it matched. Files absent at parse time are tracked deliberately
— an `annotations.yml` the build has yet to generate is the common staleness trigger. Remote
sources (git/maven/npm/pypi) are version-pinned downloads materialized into a temp directory that
is removed after parsing, so they are not fingerprinted and never go stale.
- Add `ProjectSession.ensure_fresh()`: re-stat the fingerprint, rebuild only if it no longer
matches. The LSP keeps calling `rebuild()` directly from its client file-change notifications;
MCP has no such channel, which is why it checks per request.
- **MCP tools resolve the repository per call** instead of closing over it at startup. Without
this, reloading would be invisible to the tools.
- **A failed reload is an error, not a fallback.** If the inputs changed but the new state does not
parse, tools raise `SnapshotReloadError` rather than answering from the superseded snapshot. The
failed build re-stamps the inputs it knew about, so a broken tree is not re-parsed on every
request until it is fixed.
- Add a `refresh` tool that reloads unconditionally, and a `snapshot` field on `get_status`
reporting `built_at`, `tracked_files`, and `warnings` — where a `test_results` pattern matching
no files is reported explicitly instead of being counted as zero tests.

## Capabilities

### New Capabilities
<!-- none -->

### Modified Capabilities
- `mcp`: adds requirements that the server answers from a snapshot reflecting the current state of
the served project's local files (reloading automatically and on request), that a reload which
fails is reported as an error rather than answered from the superseded snapshot, and that the
snapshot's parse time and any test-result patterns matching no files are reported.

## Impact

- `src/reqstool/common/snapshot_fingerprint.py` — new; the fingerprint and its staleness check.
- `src/reqstool/model_generators/combined_raw_datasets_generator.py` — records the fingerprint per
parsed source; `__parse_source_other` now also returns the test-result files each pattern matched.
- `src/reqstool/models/raw_datasets.py` — `fingerprint` on `RawDataset` and `CombinedRawDataset`.
- `src/reqstool/common/project_session.py` — fingerprint, `built_at`, `initial_urn`,
`ensure_fresh()`, and a lock around rebuilds.
- `src/reqstool/mcp/server.py` — per-call repository resolution, `refresh` tool, `snapshot` field.
- MCP clients — `get_status` gains a `snapshot` field (additive); tool calls can now fail with a
load error where they previously returned stale numbers (intentional).
- Not changed: the CLI, which parses per invocation and was never affected.

## Non-goals

- A `verify` tool with CLI-gate semantics (issue #437, point 3). It belongs with the `status`/
`export` redesign in #311 and must derive from the shared verdict computation (MCP_0005), so it
is deliberately left out of this change.
- Watching remote sources. A `GitLocation` pinned to a moving branch ref is technically mutable;
`refresh` covers it.
19 changes: 19 additions & 0 deletions openspec/changes/mcp-snapshot-freshness/specs/mcp/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## ADDED Requirements

### Requirement: MCP_0006
The system SHALL implement MCP_0006.

#### Scenario: SVC_MCP_0006
The system SHALL pass SVC_MCP_0006.

### Requirement: MCP_0007
The system SHALL implement MCP_0007.

#### Scenario: SVC_MCP_0007
The system SHALL pass SVC_MCP_0007.

### Requirement: MCP_0008
The system SHALL implement MCP_0008.

#### Scenario: SVC_MCP_0008
The system SHALL pass SVC_MCP_0008.
49 changes: 49 additions & 0 deletions openspec/changes/mcp-snapshot-freshness/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## 1. reqstool SSOT

- [x] 1.1 Add `MCP_0006` (snapshot freshness), `MCP_0007` (reload failure reporting) and `MCP_0008` (snapshot provenance and missing artifact reporting) to `docs/reqstool/requirements.yml` under the mcp capability block
- [x] 1.2 Add `SVC_MCP_0006`, `SVC_MCP_0007` and `SVC_MCP_0008` to `docs/reqstool/software_verification_cases.yml`
- [x] 1.3 Run `openspec validate mcp-snapshot-freshness --type change --strict` and confirm it passes

## 2. Fingerprint the parsed inputs

- [x] 2.1 Add `SnapshotFingerprint` (`FileStamp`, `GlobSpec`) with `stale_reasons()`, `is_stale()`, `restamped()` and `warnings()`
- [x] 2.2 Capture it per parsed source in `CombinedRawDatasetsGenerator.__parse_source`, for `LocalLocation` only, recording paths under the real project directory rather than the temp symlink tree
- [x] 2.3 Stamp the four data YAMLs and `reqstool_config.yml` whether or not they exist, so a file the build generates later registers as a change
- [x] 2.4 Record each `test_results` pattern with the concrete files it matched — `__parse_source_other` returns them instead of discarding them
- [x] 2.5 Aggregate onto `CombinedRawDataset`, mirroring `urn_source_paths`

## 3. Reload the session when its inputs change

- [x] 3.1 Track `fingerprint`, `built_at` and `initial_urn` on `ProjectSession`
- [x] 3.2 Add `ensure_fresh()` — rebuild only when the fingerprint no longer matches disk; raise `SnapshotReloadError` when the session cannot serve a snapshot that matches
- [x] 3.3 Re-stamp the known inputs after a failed build, so a tree that does not parse is not re-parsed on every request
- [x] 3.4 Guard build/close/ensure_fresh with a lock — a reload replaces the database underneath request handlers
- [x] 3.5 Leave the LSP path alone: it rebuilds from client file-change notifications and does not call `ensure_fresh()`

## 4. MCP server

- [x] 4.1 Resolve the repository per tool call via a `_repo()` helper instead of binding it at startup — reloading is invisible to the tools otherwise
- [x] 4.2 Add a `refresh` tool that reloads unconditionally and reports the resulting snapshot
- [x] 4.3 Add a `snapshot` field to `get_status` with `built_at`, `reload`, `tracked_files` and `warnings`
- [x] 4.4 Report `test_results` patterns matching no files as warnings; report a missing annotations file only for the served URN, since imported sources are not expected to carry annotations
- [x] 4.5 Add `@Requirements` annotations for `MCP_0006`, `MCP_0007` and `MCP_0008`

## 5. Tests

- [x] 5.1 Unit-test the fingerprint: modified, removed and later-created files; new and rewritten test results; zero-match patterns; `restamped()`
- [x] 5.2 Unit-test `ProjectSession.ensure_fresh()` against a writable copy of a fixture: unchanged tree is not rebuilt, changed annotations and test results are picked up, a broken tree errors and recovers once fixed
- [x] 5.3 Unit-test the MCP tools driven while the project changes underneath a live server
- [x] 5.4 Integration-test a real spawned server (`tests/integration/reqstool/mcp/test_mcp_reload_integration.py`) against a writable project copy: annotations added after startup are served, `refresh` reloads, a project that no longer parses errors rather than answering stale
- [x] 5.5 Add `@SVCs` annotations to the verifying tests

## 6. Documentation

- [x] 6.1 Document the `refresh` tool and the `snapshot` field in `docs/modules/ROOT/pages/mcp.adoc`
- [x] 6.2 Add a "Snapshot Freshness" section covering what is watched, what happens when a reload fails, and that remote sources are not watched

## 7. Verification

- [x] 7.1 Run `hatch run dev:pytest --cov=reqstool` (unit and integration) and `hatch run dev:flake8`
- [x] 7.2 Run the CLAUDE.md regression smoke diffs against `main` — CLI output must be byte-identical
- [x] 7.3 Run `reqstool status local -p docs/reqstool` and confirm the new SVCs are covered
- [x] 7.4 Run `openspec validate --all --strict`
11 changes: 11 additions & 0 deletions src/reqstool/common/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ def __init__(self, ref: str, url: str):
super().__init__(f"ref '{ref}' not found in {url}")


class SnapshotReloadError(Exception):
"""Raised when a session's input files changed but the new state cannot be parsed.

Answering from the superseded snapshot would be a well-formed but wrong answer, so
callers get this instead.
"""

def __init__(self, message: str):
super().__init__(message)


class EnvVarInterpolationError(Exception):
"""Raised when environment variable interpolation of YAML input fails.

Expand Down
Loading