Skip to content

Commit d40f7e9

Browse files
authored
Merge pull request #242 from morluto/agent/address-shared-corpus-contracts
feat(mcp): bind offline workflows to corpus revisions
2 parents 20a9762 + 303f7b1 commit d40f7e9

55 files changed

Lines changed: 3342 additions & 670 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# External validation receipts
2+
3+
`internal/evidence/testdata/flameox-profiler-receipt.json` is a checked-in
4+
Flameox-compatible example mapped to the producer-neutral
5+
`gitcontribute.external-validation.v1` contract. It is fixture data only: the
6+
repository does not depend on or execute Flameox.
7+
8+
The field mapping is intentionally direct:
9+
10+
| Profiler handoff | GitContribute receipt field |
11+
| --- | --- |
12+
| producer name | `producer` |
13+
| profiler run identity | `provider`, `external_run_id` |
14+
| source repository and commit | `repository`, `revision` |
15+
| primary output digest | `artifact_sha256` |
16+
| named output digests | `artifacts` |
17+
| invoked command and workspace label | `argv`, `working_dir` |
18+
| start/end and process result | `started_at`, `completed_at`, `exit_code`, `classification` |
19+
| partial profiler output | `incomplete`, `limitations`, `truncated` |
20+
21+
Receipts are decoded with unknown-field rejection and validated for schema,
22+
provenance, SHA-256 digests, timestamps, bounded output, incomplete status, and
23+
round-trip preservation. Attaching a receipt records the supplied observation;
24+
it never invokes the named producer.

docs/mcp-composed-workflows.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ telemetry.
3131

3232
## Issue or pull-request research brief
3333

34+
For a complete source audit, the canonical route is:
35+
36+
```text
37+
coverage -> explicit sync -> jobs.get -> offline reread -> duplicate checks -> live verification -> receipt attachment -> evidence/draft handoff
38+
```
39+
40+
Coverage reads are offline and missing coverage is unknown. Synchronization is
41+
always an explicit bounded operation; after `jobs.get`, perform the offline
42+
reread and reuse its returned `corpus_revision` for any composed duplicate
43+
checks. Use exact resource URIs only through MCP `resources/read` before attaching
44+
receipts or handing evidence to a draft workflow.
45+
3446
Canonical MCP composition:
3547

3648
1. Use `workflow.prepare_issue_set` for supplied exact issues. It returns

docs/mcp-scalable-workflows.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ github.sync_repository_context -> jobs.get -> corpus.get_repositories
1515
research.query_deepwiki
1616
github.sync_threads -> jobs.get -> corpus.rank_contribution_candidates
1717
github.sync_thread_facets -> jobs.get -> corpus.get_thread_facets
18-
corpus.find_precedents -> workflow.find_related_work
18+
corpus.find_precedents
1919
workflow.prepare_issue_set
2020
```
2121

@@ -58,6 +58,13 @@ explicit `raw_query` field; there is no deprecated alias.
5858
- `corpus.find_clusters` and `corpus.find_neighbors` accept up to 20 repository
5959
or source-thread targets respectively. Their ordered item results isolate
6060
missing or invalid targets instead of forcing scalar retry loops.
61+
- Revision-bound offline reads return a `corpus_revision`. The revision-bearing
62+
search, coverage, precedent, cluster, neighbor, portfolio, code, and issue-set
63+
surfaces accept that value as a pin for a composed follow-up read. A stale pin
64+
is an unavailable result; reread only after an explicit, bounded
65+
synchronization job. Other point reads and durable resources may omit a
66+
revision, so do not infer that they share a snapshot unless their contract
67+
returns and accepts the pin. Reads never refresh the corpus implicitly.
6168
- `corpus.rank_contribution_candidates` requires one to 50 repositories. Its derived ranking is
6269
intentionally non-paginated; inspect `total` and `truncated`, then raise the
6370
limit or narrow the repository set when more candidates are needed. Per-repo
@@ -92,8 +99,9 @@ categories over an explicit observation window. It searches the local corpus
9299
first, refreshes only a bounded set of finalists whose merge outcome is
93100
unknown, and persists a typed report. `candidate_limit`, `hydration_limit`, and
94101
`representative_limit` bound search, network work, and returned context
95-
independently. Set `hydration_limit: 0` to request a strictly offline analysis;
96-
otherwise the workflow performs GitHub reads and idempotent local writes.
102+
independently. The durable operation always creates a job and persists its
103+
report. Use `corpus.preview_fix_patterns` when the analysis must be strictly
104+
offline and must create no job, artifact, hydration, or local write.
97105

98106
Coverage reports candidate matches, unique pull requests, unknown outcomes
99107
before and after hydration, hydration failures, and candidate truncation.
@@ -106,6 +114,11 @@ Relationship and proof-style labels are bounded lexical projections, so the
106114
report preserves their supporting phrase and states that similarity is not
107115
causal proof.
108116

117+
For an analysis that must not create a job, artifact, hydration, or write, use
118+
`corpus.preview_fix_patterns`. It returns `persisted: false`, zero hydration,
119+
and the captured `corpus_revision`. The durable operation remains the path for
120+
persisted reports.
121+
109122
## Exact issue-set preparation
110123

111124
Use `workflow.prepare_issue_set` when the contribution is already scoped by
@@ -208,6 +221,31 @@ Repository and dossier absence have different recovery paths:
208221

209222
Reading the dossier resource again cannot resolve either state.
210223

224+
## Canonical source audit
225+
226+
Use this order when producing a source-backed audit or contribution handoff:
227+
228+
```text
229+
coverage -> explicit sync -> jobs.get -> offline reread -> duplicate checks -> live verification -> receipt attachment -> evidence/draft handoff
230+
```
231+
232+
Start with `corpus.get_coverage` and treat missing or incomplete coverage as
233+
unknown. If current evidence is required, choose a bounded explicit GitHub
234+
sync, poll it with `jobs.get`, and then perform the offline reread. Use the
235+
reread's returned `corpus_revision` for duplicate checks over that same state.
236+
Perform live verification after local evidence selection, attach a producer-neutral
237+
validation receipt, and hand the exact resource and any returned revision
238+
references to the evidence or draft workflow. Resources that do not expose a
239+
revision are point-in-time reads and should be reread after an explicit sync.
240+
Larger persisted payloads are always read with MCP `resources/read` using the
241+
exact opaque URI returned by the tool.
242+
243+
Completed code-index jobs return a typed artifact containing repository, commit
244+
SHA, corpus revision, manifest identity and digest, file/truncation counts, and
245+
an exact `gitcontribute://code-index/...` resource. Consume that URI through
246+
`resources/read`; do not infer an artifact identity from a repository name
247+
alone.
248+
211249
`corpus.get_coverage` accepts up to 100 ordered repository or exact-thread
212250
targets. `jobs.cancel` accepts up to 100 IDs and returns isolated item outcomes;
213251
repeating cancellation is safe. `jobs.get` exposes structured phase and item

internal/app/acquisition.go

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -64,26 +64,31 @@ func (s *Service) Acquire(ctx context.Context, repo contracts.RepoRef, remote st
6464
if confirmedCommit != acq.CommitSHA {
6565
return nil, fmt.Errorf("acquired checkout changed before snapshot reuse: commit %q", confirmedCommit)
6666
}
67+
revision, err := c.CorpusRevision(ctx)
68+
if err != nil {
69+
return nil, err
70+
}
6771
return &contracts.AcquisitionResult{
68-
Repo: repo,
69-
Remote: acq.Remote,
70-
DefaultBranch: acq.DefaultBranch,
71-
CommitSHA: acq.CommitSHA,
72-
Files: existing.Manifest.IndexedFiles,
73-
Bytes: existing.TotalBytes,
74-
Indexed: true,
75-
Inserted: false,
76-
AcquiredAt: formatTime(acq.AcquiredAt),
77-
Message: "acquired; snapshot already indexed",
78-
IndexManifest: existing.Manifest,
72+
Repo: repo,
73+
Remote: acq.Remote,
74+
DefaultBranch: acq.DefaultBranch,
75+
CommitSHA: acq.CommitSHA,
76+
Files: existing.Manifest.IndexedFiles,
77+
Bytes: existing.TotalBytes,
78+
Indexed: true,
79+
Inserted: false,
80+
AcquiredAt: formatTime(acq.AcquiredAt),
81+
Message: "acquired; snapshot already indexed",
82+
IndexManifest: existing.Manifest,
83+
CorpusRevision: revision,
7984
}, nil
8085
}
8186

8287
snapshot, err := codeindex.Index(ctx, acq.Path, codeindex.Options{})
8388
if err != nil {
8489
return nil, fmt.Errorf("index acquired checkout: %w", err)
8590
}
86-
_, inserted, err := c.StoreCodeSnapshot(ctx, ref, snapshot)
91+
_, inserted, revision, err := c.StoreCodeSnapshotWithRevision(ctx, ref, snapshot)
8792
if err != nil {
8893
return nil, fmt.Errorf("store code snapshot: %w", err)
8994
}
@@ -94,16 +99,17 @@ func (s *Service) Acquire(ctx context.Context, repo contracts.RepoRef, remote st
9499
}
95100

96101
return &contracts.AcquisitionResult{
97-
Repo: repo,
98-
Remote: acq.Remote,
99-
DefaultBranch: acq.DefaultBranch,
100-
CommitSHA: acq.CommitSHA,
101-
Files: len(snapshot.Documents),
102-
Bytes: snapshot.TotalBytes,
103-
Indexed: true,
104-
Inserted: inserted,
105-
AcquiredAt: formatTime(acq.AcquiredAt),
106-
Message: message,
107-
IndexManifest: snapshot.Manifest,
102+
Repo: repo,
103+
Remote: acq.Remote,
104+
DefaultBranch: acq.DefaultBranch,
105+
CommitSHA: acq.CommitSHA,
106+
Files: len(snapshot.Documents),
107+
Bytes: snapshot.TotalBytes,
108+
Indexed: true,
109+
Inserted: inserted,
110+
AcquiredAt: formatTime(acq.AcquiredAt),
111+
Message: message,
112+
IndexManifest: snapshot.Manifest,
113+
CorpusRevision: revision,
108114
}, nil
109115
}

internal/app/hydration_refresh.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contrac
4848
writer := &syncThreadWriter{
4949
ctx: ctx,
5050
corpus: c,
51+
owner: ref.Owner,
52+
repo: ref.Repo,
5153
repositoryID: repository.ID,
5254
kind: kind,
5355
}

internal/app/job_executor_reconciliation_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,19 @@ func TestLiveOwnerNotReconciledByAnotherExecutor(t *testing.T) {
8787
<-blocked
8888
waitForJobStatus(t, jobsA, id, corpus.JobStatusRunning, 1*time.Second)
8989

90-
// A second process opens the database and reconciles with a 200ms lease.
91-
// Because A heartbeats every 50ms, its job must remain running.
90+
// A second process opens the database and reconciles with a lease that is
91+
// deliberately much wider than the heartbeat interval. This keeps the
92+
// assertion about ownership independent of scheduler jitter while still
93+
// allowing the test to catch an owner that stops heartbeating.
94+
const leaseTimeout = 2 * time.Second
9295
cB, err := corpus.Open(ctx, svc.databasePath())
9396
if err != nil {
9497
t.Fatalf("open second corpus: %v", err)
9598
}
9699
defer func() { _ = cB.Close() }()
97100

98101
time.Sleep(100 * time.Millisecond)
99-
if err := cB.ReconcileInterruptedJobs(ctx, 200*time.Millisecond); err != nil {
102+
if err := cB.ReconcileInterruptedJobs(ctx, leaseTimeout); err != nil {
100103
t.Fatalf("reconcile: %v", err)
101104
}
102105

internal/app/manifest.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ const manifestSnapshotAttempts = 3
2525

2626
// ManifestOptions selects optional local workspace and exact stored PR inputs.
2727
type ManifestOptions struct {
28-
WorkspaceID string
29-
PullRequest *ManifestPullRequest
28+
WorkspaceID string
29+
PullRequest *ManifestPullRequest
30+
CorpusRevision *int64
3031
}
3132

3233
// ManifestPullRequest identifies one exact stored pull request.
@@ -38,42 +39,64 @@ type ManifestPullRequest struct {
3839

3940
// ContributionManifest assembles and persists one bounded local evidence statement.
4041
func (s *Service) ContributionManifest(ctx context.Context, opportunityID string, opts ManifestOptions) (*manifest.Statement, error) {
42+
statement, _, err := s.contributionManifestWithRevision(ctx, opportunityID, opts)
43+
return statement, err
44+
}
45+
46+
// contributionManifestWithRevision returns the revision validated while the
47+
// statement was assembled. Persisting the statement advances the corpus, so a
48+
// later standalone revision read would identify the write rather than the
49+
// evidence selected by this manifest.
50+
func (s *Service) contributionManifestWithRevision(ctx context.Context, opportunityID string, opts ManifestOptions) (*manifest.Statement, int64, error) {
4151
c, err := s.openCorpus(ctx)
4252
if err != nil {
43-
return nil, err
53+
return nil, 0, err
4454
}
4555
for attempt := 0; attempt < manifestSnapshotAttempts; attempt++ {
56+
revision, err := c.CorpusRevision(ctx)
57+
if err != nil {
58+
return nil, 0, err
59+
}
60+
if opts.CorpusRevision != nil && *opts.CorpusRevision != revision {
61+
return nil, 0, &corpus.StaleCorpusRevisionError{Expected: *opts.CorpusRevision, Current: revision}
62+
}
4663
watch, err := c.BeginChangeWatch(ctx)
4764
if err != nil {
48-
return nil, err
65+
return nil, 0, err
4966
}
5067
statement, err := s.assembleContributionManifest(ctx, c, opportunityID, opts)
5168
if err != nil {
5269
_ = watch.Close()
53-
return nil, err
70+
return nil, 0, err
5471
}
5572
unchanged, watchErr := watch.Unchanged(ctx)
5673
closeErr := watch.Close()
5774
if watchErr != nil || closeErr != nil {
58-
return nil, errors.Join(watchErr, closeErr)
75+
return nil, 0, errors.Join(watchErr, closeErr)
5976
}
6077
if !unchanged {
6178
continue
6279
}
80+
if err := c.RequireCorpusRevision(ctx, revision); err != nil {
81+
if opts.CorpusRevision == nil && corpus.IsStaleCorpusRevision(err) {
82+
continue
83+
}
84+
return nil, 0, err
85+
}
6386
if err := validateManifestReferences(statement.Predicate); err != nil {
64-
return nil, err
87+
return nil, 0, err
6588
}
6689
pullRequestRef := ""
6790
if statement.Predicate.PullRequest != nil {
6891
pr := statement.Predicate.PullRequest
6992
pullRequestRef = fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number)
7093
}
7194
if err := c.SaveContributionManifest(ctx, statement, opts.WorkspaceID, pullRequestRef); err != nil {
72-
return nil, err
95+
return nil, 0, err
7396
}
74-
return statement, nil
97+
return statement, revision, nil
7598
}
76-
return nil, errors.New("corpus changed while assembling contribution manifest; retry")
99+
return nil, 0, errors.New("corpus changed while assembling contribution manifest; retry")
77100
}
78101

79102
func (s *Service) assembleContributionManifest(ctx context.Context, c *corpus.Corpus, opportunityID string, opts ManifestOptions) (*manifest.Statement, error) {

internal/app/mcp.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp
8787
Limit: in.Limit,
8888
Cursor: in.Cursor,
8989
Sort: in.Sort, MatchMode: in.MatchMode,
90+
CorpusRevision: in.CorpusRevision,
9091
})
9192
if err != nil {
9293
return mcpcontract.SearchOutput{}, err
@@ -116,6 +117,7 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp
116117
MatchSource: m.MatchSource,
117118
MatchExcerpt: m.MatchExcerpt,
118119
MatchTruncated: m.MatchTruncated,
120+
CorpusRevision: res.CorpusRevision,
119121
}
120122
if in.View == "full" {
121123
matches[i].Body = m.Body
@@ -132,6 +134,7 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp
132134
Query: in.Query, QueryInterpretation: strings.Join(strings.Fields(in.Query), separator),
133135
MatchMode: in.MatchMode, View: in.View, Total: res.Total, Matches: matches, NextCursor: res.NextCursor,
134136
UnknownMergeCount: res.UnknownMergeCount,
137+
CorpusRevision: res.CorpusRevision,
135138
}
136139
if out.UnknownMergeCount > 0 {
137140
out.Suggestion = "Some otherwise-matching pull requests have unknown merge state. Repeat without the merged filter to identify finalists, then hydrate pr_details before inferring absence."
@@ -175,6 +178,10 @@ func (r *MCPReader) Thread(ctx context.Context, in mcpcontract.ThreadInput) (mcp
175178
if err != nil {
176179
return mcpcontract.ThreadOutput{}, err
177180
}
181+
revision, err := beginCorpusRead(ctx, c, nil)
182+
if err != nil {
183+
return mcpcontract.ThreadOutput{}, err
184+
}
178185
repo, err := c.GetRepository(ctx, in.Owner, in.Repo)
179186
if err != nil {
180187
return mcpcontract.ThreadOutput{}, fmt.Errorf("get repository: %w", err)
@@ -192,6 +199,10 @@ func (r *MCPReader) Thread(ctx context.Context, in mcpcontract.ThreadInput) (mcp
192199
out := corpusThreadToMCPOutput(thread)
193200
out.Owner = in.Owner
194201
out.Repo = in.Repo
202+
out.CorpusRevision = revision
203+
if err := finishCorpusRead(ctx, c, revision); err != nil {
204+
return mcpcontract.ThreadOutput{}, err
205+
}
195206
return out, nil
196207
}
197208

@@ -603,7 +614,11 @@ func (r *MCPReader) GetCoverage(ctx context.Context, in mcpcontract.GetCoverageI
603614
if err != nil {
604615
return mcpcontract.GetCoverageOutput{}, err
605616
}
606-
out := mcpcontract.GetCoverageOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.CoverageOutput], len(in.Targets))}
617+
revision, err := beginCorpusRead(ctx, c, in.CorpusRevision)
618+
if err != nil {
619+
return mcpcontract.GetCoverageOutput{}, err
620+
}
621+
out := mcpcontract.GetCoverageOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.CoverageOutput], len(in.Targets)), CorpusRevision: revision}
607622
for i, target := range in.Targets {
608623
if err := ctx.Err(); err != nil {
609624
return out, err
@@ -635,6 +650,9 @@ func (r *MCPReader) GetCoverage(ctx context.Context, in mcpcontract.GetCoverageI
635650
}
636651
out.Items[i] = item
637652
}
653+
if err := finishCorpusRead(ctx, c, revision); err != nil {
654+
return mcpcontract.GetCoverageOutput{}, err
655+
}
638656
return out, nil
639657
}
640658

@@ -653,8 +671,14 @@ func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac
653671
if err := ref.Validate(); err != nil {
654672
return mcpcontract.CoverageOutput{}, "invalid_reference", fmt.Errorf("%w: %w", errInvalidCoverageTarget, err)
655673
}
656-
isThread := target.Kind != "" || target.Number != 0
657-
if isThread && ((target.Kind != "issue" && target.Kind != "pull_request") || target.Number < 1) {
674+
isThread, valid := (target.Kind != "" || target.Number != 0), false
675+
switch {
676+
case target.Kind == "" && target.Number == 0:
677+
valid = true
678+
case target.Kind == "issue" || target.Kind == "pull_request":
679+
valid = target.Number > 0
680+
}
681+
if !valid {
658682
return mcpcontract.CoverageOutput{}, "invalid_reference", errInvalidCoverageTarget
659683
}
660684
repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo)

0 commit comments

Comments
 (0)