Skip to content

feat(session): versioned run manifest + partial-coverage contract#379

Open
chethanuk wants to merge 7 commits into
alibaba:mainfrom
chethanuk:feat/issue-367-run-manifest
Open

feat(session): versioned run manifest + partial-coverage contract#379
chethanuk wants to merge 7 commits into
alibaba:mainfrom
chethanuk:feat/issue-367-run-manifest

Conversation

@chethanuk

@chethanuk chethanuk commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Description

Implements a versioned, immutable run manifest and an explicit partial-coverage contract for review/scan runs, per alibaba/open-code-review#367. Builds on the merged sessions/checkpoints substrate (PR #306).

The manifest is one new append-only run_manifest JSONL record, written exactly once by SessionHistory.Finalize() as the last line of the existing session file, held in memory, and surfaced verbatim as jsonOutput.Manifest in --format json — the same Go value serialized in both places, so persisted and reported coverage are identical by construction. No sidecar file. Immutability = append-only + written-once; a resumed run creates a new session file whose manifest carries parent_session_id.

What's in it

  • Terminal state contract (internal/session/manifest.go): a pure ComputeTerminalState(selected, completed, reused, failed, waived, cancelled)complete / partial / failed / skipped. selected is the coverage denominator — a superset of completed ∪ reused ∪ failed ∪ waived, equal for fully-covered runs and strictly larger for a cancelled/budget-truncated partial. A waive satisfies coverage (counts like a completed review).
  • Typed failure classes: provider_error / timeout / cancelled / panic / skipped_limit, via ClassifyFailure(err) (context-error chain) plus the explicit panic/skip paths. Recorded per item as failureClass; the legacy subtask_error warning is untouched.
  • Redacted metadata (cmd/opencodereview/runmeta.go): config_hash over an allowlisted, non-secret field set (protocol, model, base-URL host with userinfo+query stripped, language, timeout) — stable across API-key rotation, never carries a credential; rules_hash per rule layer; repo identity (origin remote with userinfo/query stripped, HEAD SHA); resolved range SHAs.
  • --waive path,... (requires --resume): waived diffs are skipped, recorded as review_item_waived, count toward coverage, and stay covered on later resumes.
  • --format json gains a manifest object; the legacy status values are unchanged (regression-locked by test). Exit code stays 0 for partial (main.go untouched) — manifest.state is the machine contract, no --fail-on flag.
  • Scan path has full manifest parity, including panic isolation (a panicking subtask is recorded as class panic and the manifest still finalizes).
  • Docs: manifest schema + state→exit-code mapping + --waive added to pages/src/content/docs/en/cli-reference.md.

Commits (7: 6 feature slices + 1 tests/hardening slice)

Commit Slice
c8b7f35 feat(session): manifest record, terminal-state, coverage tracking
9411ee4 feat(agent): selected set, artifact checksum, cancellation marking
a6ff4a1 feat(cmd): run metadata (version, provider, hashes, repo identity, range SHAs)
9ba43ff feat(output): expose manifest in --format json
7c9c476 feat(scan): manifest parity for full scans
ff745d4 feat(review): --waive for resumed runs
dbe57fa chore(tests): acceptance matrix + provider-transition + secret guard; docs + hardening

Hardening (in dbe57fa)

  • Viewer session list: peekSession (the list fast path) read only the last JSONL line to find session_end; now that run_manifest is the last line, duration/file-count/failures showed as zero for every manifest-era session. Fixed to check the last two lines; TestPeekSession_RunManifestAfterSessionEnd fails on the pre-fix code.
  • A cancelled run whose only covered items are waives now reports partial, not failed (waive satisfies coverage).
  • Finalize is idempotent (written-once guard) so immutability is enforced, not incidental.
  • [ocr session] messages go to os.Stderr (all 4 sites, incl. 2 pre-existing), so --format json stdout stays pure.
  • redactRemoteURL strips userinfo and query/fragment: ?access_token=… can never reach the manifest. Verified E2E. scp-style remotes (git@host:path) pass through — they fail url.Parse and carry no password.
  • scanFingerprint is content-based (mode\0path\0content), matching the diff path's content-sensitive fingerprint.
  • Scan dispatch goroutines recover, record class panic, and still finalize the manifest — mirrors the diff-review dispatch.
  • session.ArtifactChecksum de-dupes the checksum tail shared by the agent/scan paths; TestManifestSchemaLock pins the literal schema version + top-level key set; the resume acceptance test proves a partial run flips to complete on resume with an unchanged artifact checksum.
  • rules_hash tracks resolved rules: rule entries that reference files (e.g. a rule.json entry pointing at team.md) are expanded to content by rules.NewResolver; the hash now covers the resolved layers (via a small UserLayers accessor) instead of the top-level rule.json bytes, so editing a referenced rule file changes the hash. Test locks it.
  • Scan checkpoints drain async comments: scan's executeSubtask now Awaits the CommentWorkerPool before reading CommentsForPath and persisting review_item_done — mirrors the per-file Await the diff path already had; without it a checkpoint could persist an incomplete comment set.
  • Oversized pre-filtered files stay in the coverage denominator: filterLargeDiffs/filterLargeScans record each dropped file as failed with class skipped_limit, so a partially filtered run reports partial (not complete with the file silently missing) and an all-filtered run reports failed with per-file failures (not an empty skipped). Table rows + a scan filter test lock this.

Deviations from the design (all minor, adapted to real code)

  • session_end no longer closes the file; Finalize writes session_endrun_manifest → close, so the manifest stays the last line. All existing readers (list.walkSessionFile, resume.applyResumeLine, viewer/store) tolerate a trailing unknown record — verified.
  • Failure-class caller updates were folded into slice 1 (changing RecordReviewItemFailed's signature required updating callers immediately to keep the tree building between commits).
  • rules_hash hashes the resolver's already-loaded rule layers (one small UserLayers accessor on the rules package) rather than re-reading files — the hash covers resolved rule content, including files referenced from rule.json.
  • A lone token-skipped_limit file yields state: failed (since failed == selected), per the spec table — noted so it isn't surprising.
  • Docs added to en/cli-reference.md only; ja/zh translations left for a follow-up.

Type of Change

  • New feature (non-breaking change that adds functionality)

How Has This Been Tested?

  • make test passes locally (full -race suite, EXIT=0)
  • make check passes (tidy + fmt + vet, EXIT=0)
  • Live E2E: ran a real review and inspected the persisted session — record order is … → session_end → run_manifest (manifest is the last line); a failing review produced state: failed, files: {selected:1, failed:1}, failures[].class: provider_error.
  • Secret-scan (load-bearing): grepped the emitted session file for the API key, authorization, bearer, x-api-key, auth_token, :token@, sk-ant → none present; a remote configured with userinfo+query credentials persists fully redacted. A test-level secret guard also asserts this on a synthesized manifest.

Checklist

  • My code follows the project's coding style (go fmt, go vet)
  • I have performed a self-review of my code
  • I have added tests that prove the feature works (~1050 LOC test)
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly

Related Issues

Addresses alibaba/open-code-review#367.

…age tracking

Add an append-only run_manifest JSONL record written once by Finalize as the
last line of the session file and retained in memory (Manifest() getter), so
persisted and reported coverage are identical by construction.

- manifest.go: RunManifest struct, pure ComputeTerminalState (table over
  skipped/complete/failed/partial incl. cancelled + waive), failure-class
  constants + ClassifyFailure.
- history.go: RunMeta on SessionOptions; coverage sets tracked on
  SessionHistory (selected/completed/reused/failed/waived + typed failures);
  SetSelected/SetArtifactChecksum/MarkCancelled/RecordReviewItemWaived;
  Finalize computes state and writes the manifest as the final record.
- persist.go: WriteRunManifest + WriteReviewItemWaived; failureClass added to
  review_item_failed; session_end no longer closes so the manifest stays last.
- list.go: Summary.State populated from the run_manifest record.

RecordReviewItemFailed gains a class param; existing agent callers pass
panic/skipped_limit/ClassifyFailure(err).
Wire the run-manifest coverage denominator into the diff-review path:
- recordSelectionAndArtifact records the non-deleted reviewable set
  (SetSelected) and an order-independent sha256 over the sorted per-file
  fingerprints (SetArtifactChecksum) before resume/dispatch, so
  selected == completed + reused + failed + waived holds.
- Run marks the session cancelled when the context is cancelled.

Failure classes (panic/skipped_limit/ClassifyFailure) were threaded through
the dispatch paths in the prior commit. Adds a stub-LLM table test over
success/mixed/timeout/cancel/panic/skip asserting manifest state + classes,
plus an artifact-checksum order-independence test.
…ntity, resolved range)

runmeta.go builds session.RunMeta for review and scan:
- config_hash: sha256 over an allowlisted, non-secret field set
  {provider_protocol, model, base_url_host (userinfo+query stripped),
  language, timeout}. Token/AuthHeader/ExtraHeaders are never in the input, so
  the hash is stable across key rotation.
- rules_hash: sha256 over (label \0 raw bytes) of each loaded rule layer
  (custom/project/global) plus a label+version marker for the embedded system
  layer.
- repo identity: origin remote URL with userinfo stripped + HEAD SHA.
- resolved range SHAs rev-parsed from --from/--to/--commit.

Threaded via agent.Args.RunMeta / scan.Args.RunMeta into session.New.
llmRuntime now retains the resolved endpoint for non-secret hash inputs.
Tests cover config-hash redaction/invariance, userinfo stripping, resolved
range SHAs, and rules-hash sensitivity.
Add jsonOutput.Manifest surfaced verbatim from the run's in-memory manifest
(same value persisted as the last JSONL record). Manifest() joins the
ResultProvider interface; both agent.Agent and scan.Agent implement it.

Legacy status values (success/completed_with_errors/completed_with_warnings/
skipped) are UNCHANGED — manifest.state is the new machine contract. Adds a
regression-lock test over the status paths plus manifest-presence tests
(including the no-files skipped path).
Record coverage on the scan path so full scans emit the same manifest as diff
reviews:
- SetSelected + artifact checksum (order-independent sha256 over sorted
  path-based fingerprints) at dispatch start.
- executeSubtask records done / failed(ClassifyFailure) / skipped_limit per
  item so the coverage invariant holds.
- Run marks the session cancelled on context cancellation.

Scan has no diff/resume, so the fingerprint is path-based (ponytail note:
content-hash if scan grows resume). Adds a parity table test over
success/mixed/all-failed/cancel/skip.
Add --waive path/a.go,path/b.go (requires --resume): waived diffs are not
dispatched, are recorded as review_item_waived, and count toward coverage so a
waive can flip partial -> complete. A later resume treats a waived item as
covered (indexed like a completed item, empty comments).

No fingerprint-addressed or config-file waives — paths only (ponytail note).
Tests: waive flips partial->complete, without waive stays partial, --waive
without --resume errors.
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 8 issue(s) in this PR.

  • ✅ Successfully posted inline: 7 comment(s)
  • ❌ Failed to post inline: 1 comment(s)

📄 internal/scan/agent.go (L620-L625)

⚠️ GitHub could not post this as an inline comment: Unprocessable Entity: "Line could not be resolved"

Bug: When the token threshold is exceeded, RecordReviewItemFailed is called (correctly recording the item as failed in the manifest's coverage set), but the function returns nil instead of an error. The caller in dispatchBatch only increments subtaskFailed when executeSubtask returns a non-nil error (line 568-569). This creates an inconsistency:

  1. The manifest correctly shows the item as failed.
  2. But subtaskFailed is not incremented, so the "all scans failed" check at line 454-456 won't detect total failure when all files exceed the token limit.
  3. The batch-level warning/error logging at lines 570-574 is also skipped.

Either return an error here so the caller handles it consistently, or increment subtaskFailed explicitly before returning nil.

Comment on lines +319 to 326
switch set {
case "completed":
sh.completed[path] = struct{}{}
case "reused":
sh.reused[path] = struct{}{}
case "waived":
sh.waived[path] = struct{}{}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trackCoverage switch handles "completed", "reused", and "waived" but silently ignores any other value, including "failed". Meanwhile, RecordReviewItemFailed bypasses trackCoverage entirely and manually manages sh.failed + sh.failures under lock. This asymmetry is fragile: if a future refactor attempts to consolidate by calling trackCoverage("failed", path), failures would be silently dropped because there is no case "failed" branch.

Either add a case "failed" branch here (and have RecordReviewItemFailed use trackCoverage plus a separate locked append for sh.failures), or add a default case that panics/logs to make unsupported set names fail loudly rather than silently.

Suggestion:

Suggested change
switch set {
case "completed":
sh.completed[path] = struct{}{}
case "reused":
sh.reused[path] = struct{}{}
case "waived":
sh.waived[path] = struct{}{}
}
switch set {
case "completed":
sh.completed[path] = struct{}{}
case "reused":
sh.reused[path] = struct{}{}
case "failed":
sh.failed[path] = struct{}{}
case "waived":
sh.waived[path] = struct{}{}
default:
panic(fmt.Sprintf("trackCoverage: unknown set %q", set))
}

Comment on lines +161 to +164
func revParse(repoDir, ref string) string {
if ref == "" {
return ""
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When repoDir is empty (e.g., scan mode on a non-git directory) but ref is non-empty, this will call runGitCmdStdout("", "rev-parse", ...), which executes git -C "" rev-parse .... While currently the scan path passes an empty resolvedRange{} directly (avoiding this), the revParse function itself lacks a guard on repoDir. If future callers pass a non-empty ref with an empty repoDir, git will likely fail with a confusing error rather than returning empty gracefully as documented.

Consider adding a repoDir guard for defensive consistency with repoIdentity:

Suggestion:

Suggested change
func revParse(repoDir, ref string) string {
if ref == "" {
return ""
}
func revParse(repoDir, ref string) string {
if ref == "" || repoDir == "" {
return ""
}

Comment thread internal/scan/agent.go
Comment on lines +277 to +279
if ctx.Err() != nil || errors.Is(err, context.Canceled) {
a.session.MarkCancelled()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: ctx.Err() != nil is true for both context.Canceled and context.DeadlineExceeded. This means a deadline-exceeded run (timeout) will also call MarkCancelled(), causing ComputeTerminalState to report the run as "partial" or "failed" due to cancellation rather than reflecting the actual timeout semantics. Individual items that timed out are already correctly classified as FailureTimeout via ClassifyFailure, but the overall run state will misleadingly indicate cancellation.

Consider checking specifically for cancellation:

if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) {

Or if you intentionally want deadline-exceeded runs to also be treated as partial/failed at the manifest level, consider renaming MarkCancelled to something more general like MarkIncomplete and updating the documentation to clarify this behavior.

Comment on lines +37 to +40
func ClassifyFailure(err error) string {
switch {
case err == nil:
return FailureProviderError

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When err == nil, this function returns FailureProviderError, which is counterintuitive and potentially dangerous. A nil error conventionally means success, not a provider error. While current callers guard against passing nil (both agent.go and scan/agent.go only call ClassifyFailure(err) inside if err != nil blocks), this is an exported function and any future caller that accidentally passes nil will silently get a wrong failure class recorded in the manifest, corrupting failure statistics and terminal state computation.

Consider returning an empty string or panicking for nil to make misuse immediately visible, or at minimum document this behavior prominently.

Suggestion:

Suggested change
func ClassifyFailure(err error) string {
switch {
case err == nil:
return FailureProviderError
func ClassifyFailure(err error) string {
switch {
case err == nil:
// nil error should never reach here; callers must guard.
// Return empty to avoid misclassifying success as failure.
return ""

Comment thread internal/session/persist.go Outdated
return uuid
}

// WriteSessionEnd writes the final session_end summary record and closes the file.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says "and closes the file" but this function no longer closes the file (that responsibility was moved to flushAndClose called from Finalize). This stale comment will mislead future maintainers who rely on the godoc.

Suggestion:

Suggested change
// WriteSessionEnd writes the final session_end summary record and closes the file.
// WriteSessionEnd writes the session_end summary record. The file is NOT closed
// here; Finalize writes the run_manifest as the last record and then closes via
// flushAndClose.

Comment on lines +335 to +344
data, err := json.Marshal(m)
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr session] failed to marshal run manifest: %v\n", err)
return ""
}
rec := make(map[string]any)
if err := json.Unmarshal(data, &rec); err != nil {
fmt.Fprintf(os.Stderr, "[ocr session] failed to encode run manifest: %v\n", err)
return ""
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The marshal→unmarshal round-trip through map[string]any is unnecessary overhead and introduces a subtle type fidelity risk: integer fields (SchemaVersion, Concurrency, DurationMS) become float64 in the map, which could produce unexpected JSON output for edge-case values (e.g., very large int64 losing precision). A cleaner approach is to build the envelope map directly and embed the manifest as a nested struct field, or use a wrapper struct with both envelope and manifest fields.

return jw.writeReviewItemRecord("review_item_waived", filePath, oldPath, newPath, fingerprint, "", "", "", nil)
}

func (jw *jsonlWriter) writeReviewItemRecord(recordType, filePath, oldPath, newPath, fingerprint, sourceSessionID, failureClass, errorMsg string, comments []model.LlmComment) string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The writeReviewItemRecord function now takes 9 string/slice parameters, several of which are the same type (string). This high arity makes it easy to accidentally swap arguments (e.g., failureClass and errorMsg). Consider introducing a small options struct or record-builder pattern to reduce the risk of parameter misordering at call sites.

… docs

Tests + docs:
- acceptance_test.go: end-to-end matrix over the terminal-state scenarios
  (complete/partial/failed/skipped/cancelled/waive, plus a cancelled
  mid-run row where selected is a strict superset of the outcome sets);
  provider-transition resume case starts from a PARTIAL run and asserts
  the resume flips it to complete with parent_session_id intact and an
  unchanged artifact checksum (input identity retained);
  TestManifestNeverLeaksSecrets is the canonical secret guard.
- manifest_test.go: TestManifestSchemaLock pins the literal schema
  version (1) and the top-level JSON key set. The standalone Finalize
  test was folded into the acceptance matrix (failure-class + artifact
  columns).
- emit_run_result_test.go: no-files manifest case folded into the
  legacy-status regression table.
- agent/manifest_test.go: newManifestAgent helper replaces three copies
  of the Args literal.
- docs: run-manifest schema, state->exit-code mapping, --waive flag;
  files invariant stated with the superset wording; commit-mode range
  fields documented.

Hardening:
- viewer/store.go: peekSession read only the last JSONL line for
  session_end; run_manifest is now the last line, so the session-list
  fast path lost duration/files/failures. Checks the last two lines;
  regression test fails on the old code.
- manifest.go: cancelled run whose only covered items are waives is
  partial, not failed; ManifestFiles doc corrected (selected is a
  superset); Finalize made idempotent (written-once guard).
- session logging to stderr: the [ocr session] messages went to stdout,
  which carries the --format json payload; all four sites now use
  os.Stderr.
- redactRemoteURL (nee stripURLUserinfo) also strips query/fragment, so
  ?access_token=... can never reach the manifest; scp-style remotes
  still pass through (they fail url.Parse and carry no password).
  Verified E2E.
- scanFingerprint hashes mode\0path\0content (ScanItem.Content is
  already in memory), matching the diff path's content-sensitive
  fingerprint.
- scan dispatch goroutines now recover from panics, record the item as
  failed with class "panic", and let Finalize still write the manifest,
  mirroring the diff-review dispatch. Panic row added to the scan
  parity table.
- session.ArtifactChecksum: the sort/join/sha256 tail duplicated in the
  agent and scan recordSelectionAndArtifact now lives in one place so
  the fingerprint-join convention cannot diverge.
- computeRulesHash hashes the resolver's resolved rule layers (new
  rules.UserLayers accessor) instead of re-reading the top-level
  rule.json bytes: rule entries that reference files (e.g. team.md)
  are expanded to content by rules.NewResolver, so editing a referenced
  file now changes rules_hash even though rule.json's bytes do not.
  Test locks referenced-file edit -> hash change.
- scan executeSubtask drains the async CommentWorkerPool before reading
  CommentsForPath and persisting review_item_done; without the per-file
  Await the checkpoint could persist an incomplete comment set while
  async code_comment workers were still running (the diff path already
  had this Await).
- filterLargeDiffs/filterLargeScans record each dropped oversized file
  as failed with class skipped_limit (plus a warning), so the
  manifest's selected denominator includes pre-filtered files: a
  partially filtered run is partial, an all-filtered run is failed
  with per-file failures rather than an empty "skipped". Two agent
  table rows + a scan filter test lock this.
- persist.go/history.go: corrected stale close-ownership comments
  (WriteSessionEnd no longer closes the file; flushAndClose does).

Full go test ./... green (2037); -race green on session/agent/scan/
llmloop/cmd; make check clean. E2E: failing review persists
... -> session_end -> run_manifest with state=failed and
class=provider_error; no-diff review emits valid skipped JSON with the
manifest; a remote with userinfo+query credentials is fully redacted.
@chethanuk
chethanuk force-pushed the feat/issue-367-run-manifest branch from 95b009e to dbe57fa Compare July 15, 2026 10:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant