Skip to content
Open
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
170 changes: 170 additions & 0 deletions libs/hooks/anchor-drift.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import type { ClaimCodeAnchor } from "../knowledge-graph/claim.js";
import type {
ClaimAnchorAuditIssue,
ClaimAnchorAuditResult,
} from "../knowledge-graph/code-anchors/types.js";
import type { CompactClaim, CompactEdge, CompactMemoryProposal } from "../knowledge-graph/proposal.js";
import type { GraphMemoryProvider } from "../knowledge-graph/provider.js";
import type { ClaimId } from "../knowledge-graph/schema.js";
import type { GraphReadResult } from "../knowledge-graph/service.js";

export type ActionableAnchorStatus = Extract<
ClaimAnchorAuditIssue["status"],
"drifted" | "missing_file" | "missing_symbol"
>;

export type ActionableAnchorIssue = Omit<ClaimAnchorAuditIssue, "status" | "anchor"> & {
status: ActionableAnchorStatus;
anchor: ClaimCodeAnchor;
};

export type CheckoutSkipReason =
| "missing_cwd"
| "git_unavailable"
| "detached_head"
| "not_default_branch"
| "dirty_tracked_files";

export type CheckoutInspection =
| { eligible: true; repoRoot: string; gitHead: string }
| { eligible: false; reason: CheckoutSkipReason };

export type AnchorDriftPassResult =
| { status: "clean" }
| { status: "applied"; claimIds: ClaimId[]; memoryCommitId: string };

export function inspectAnchorDriftCheckout(cwd: string | null, defaultBranch: string): CheckoutInspection {
if (cwd === null || cwd.trim().length === 0) return { eligible: false, reason: "missing_cwd" };

const repoRoot = gitText(cwd, ["rev-parse", "--show-toplevel"]);
if (repoRoot === undefined) return { eligible: false, reason: "git_unavailable" };

const branch = gitText(repoRoot, ["branch", "--show-current"]);
if (branch === undefined) return { eligible: false, reason: "git_unavailable" };
if (branch.length === 0) return { eligible: false, reason: "detached_head" };
if (branch !== defaultBranch) return { eligible: false, reason: "not_default_branch" };

const unstaged = gitDiffStatus(repoRoot, ["diff", "--quiet", "--ignore-submodules=all", "--"]);
const staged = gitDiffStatus(repoRoot, ["diff", "--cached", "--quiet", "--ignore-submodules=all", "--"]);
if (unstaged === "error" || staged === "error") return { eligible: false, reason: "git_unavailable" };
if (unstaged === "dirty" || staged === "dirty") return { eligible: false, reason: "dirty_tracked_files" };

const gitHead = gitText(repoRoot, ["rev-parse", "HEAD"]);
return gitHead === undefined || gitHead.length === 0
? { eligible: false, reason: "git_unavailable" }
: { eligible: true, repoRoot, gitHead };
}

export function buildAnchorDriftProposal(
graph: GraphReadResult,
audit: ClaimAnchorAuditResult,
gitHead: string,
): CompactMemoryProposal | undefined {
const issuesByClaim = groupActionableIssues(audit);
const claimsById = new Map(graph.claims.map((claim) => [claim.id, claim]));
const claims: CompactClaim[] = [];
const edges: CompactEdge[] = [];

for (const claimId of [...issuesByClaim.keys()].sort()) {
const claim = claimsById.get(claimId);
if (claim === undefined || claim.truth !== "code_verified") continue;

const replacementId = anchorDriftClaimId(claim.id);
const about = graph.edges
.filter((edge) => edge.kind === "about" && edge.from_type === "claim" && edge.from_id === claim.id)
.map((edge) => edge.to_id)
.sort();

claims.push({
id: replacementId,
kind: claim.kind,
text: claim.text,
truth: "unknown",
intent: claim.intent,
...(about.length === 0 ? {} : { about }),
});
edges.push({
kind: "supersedes",
from: replacementId,
to: claim.id,
metadata: {
reason: "anchor_drift",
git_commit_sha: gitHead,
issues: issuesByClaim.get(claimId)?.map(({ status, anchor }) => ({ status, anchor })) ?? [],
},
});
}

if (claims.length === 0) return undefined;
return {
title: `Demote ${claims.length} drifted code claim${claims.length === 1 ? "" : "s"}`,
summary: "Mark claims with changed or missing code anchors as unknown while preserving their history.",
creates: { claims, edges },
};
}

export async function runAnchorDriftPass(
provider: GraphMemoryProvider,
gitHead: string,
): Promise<AnchorDriftPassResult> {
const audit = await provider.auditCodeAnchors();
if (!hasActionableIssues(audit)) return { status: "clean" };

const proposal = buildAnchorDriftProposal(await provider.readGraph(), audit, gitHead);
if (proposal === undefined) return { status: "clean" };

const result = await provider.applyProposal(proposal);
return {
status: "applied",
claimIds: (proposal.creates.edges ?? []).map((edge) => edge.to).sort(),
memoryCommitId: result.memory_commit_id,
};
}

function groupActionableIssues(audit: ClaimAnchorAuditResult): Map<ClaimId, ActionableAnchorIssue[]> {
const grouped = new Map<ClaimId, Map<string, ActionableAnchorIssue>>();
for (const issue of [...audit.drifted, ...audit.missing_files, ...audit.missing_symbols]) {
if (!isActionableAnchorIssue(issue)) continue;
const issues = grouped.get(issue.claim_id) ?? new Map<string, ActionableAnchorIssue>();
issues.set(issueKey(issue), issue);
grouped.set(issue.claim_id, issues);
}
return new Map(
[...grouped.entries()].map(([claimId, issues]) => [claimId, [...issues.values()].sort(compareIssues)]),
);
}

function hasActionableIssues(audit: ClaimAnchorAuditResult): boolean {
return [...audit.drifted, ...audit.missing_files, ...audit.missing_symbols].some(isActionableAnchorIssue);
}

function isActionableAnchorIssue(issue: ClaimAnchorAuditIssue): issue is ActionableAnchorIssue {
return issue.anchor !== undefined &&
(issue.status === "drifted" || issue.status === "missing_file" || issue.status === "missing_symbol");
}

function issueKey(issue: ActionableAnchorIssue): string {
return `${issue.status}\0${issue.anchor.file}\0${issue.anchor.symbol ?? ""}`;
}

function compareIssues(left: ActionableAnchorIssue, right: ActionableAnchorIssue): number {
return issueKey(left).localeCompare(issueKey(right));
}

function anchorDriftClaimId(claimId: ClaimId): ClaimId {
const digest = createHash("sha256").update(`anchor-drift:${claimId}`).digest("hex").slice(0, 16);
return `claim.anchor_drift.${digest}`;
}

function gitText(cwd: string, args: string[]): string | undefined {
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
return result.status === 0 ? result.stdout.trim() : undefined;
}

function gitDiffStatus(cwd: string, args: string[]): "clean" | "dirty" | "error" {
const result = spawnSync("git", ["-C", cwd, ...args], { stdio: "ignore" });
if (result.status === 0) return "clean";
return result.status === 1 ? "dirty" : "error";
}
48 changes: 46 additions & 2 deletions libs/hooks/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ClaimedMemoryUpdateAttempt } from "./types.js";
import { inspectAnchorDriftCheckout, runAnchorDriftPass } from "./anchor-drift.js";
import { LocalAgentRuntimeStore } from "./runtime-store.js";
import { WorkerLease } from "../utils/worker-lease.js";
import { ensureGreplicaConfig } from "../config/greplica-config.js";
import { canScheduleMemoryUpdates, type RepoInstallation } from "../install/repo-installation-store.js";
import { ensureGreplicaConfig, type GreplicaConfig } from "../config/greplica-config.js";
import {
canScheduleMemoryUpdates,
RepoInstallationStore,
type RepoInstallation,
} from "../install/repo-installation-store.js";
import { platformInstaller } from "../install/platforms/index.js";
import { createGraphMemoryProvider } from "../knowledge-graph/provider-factory.js";
import type { RepoRef } from "../knowledge-graph/service.js";
import { openDatabase } from "../storage/sqlite/db.js";

const hookWorkerLockName = "hook-memory-update-worker";
Expand Down Expand Up @@ -51,6 +58,16 @@ export async function runHookWorker(): Promise<void> {
const runtimeStore = new LocalAgentRuntimeStore(db, config.session);
if (!lease.renew()) return;
const attempts = runtimeStore.claimDueMemoryUpdateAttempts();
const installations = new Map(new RepoInstallationStore(db).list().map((installation) => [installation.id, installation]));
const latestAttemptByRepo = new Map<string, ClaimedMemoryUpdateAttempt>();
for (const attempt of attempts) latestAttemptByRepo.set(attempt.session.repo_id, attempt);

for (const attempt of latestAttemptByRepo.values()) {
if (!leaseValid || !lease.renew()) return;
const installation = installations.get(attempt.session.repo_id);
if (installation !== undefined) await maybeDemoteAnchorDrift(attempt, installation, config);
}

for (const attempt of attempts) {
if (!leaseValid || !lease.renew()) return;
await maybeUpdateWorkingMemory(attempt);
Expand All @@ -62,6 +79,33 @@ export async function runHookWorker(): Promise<void> {
}
}

async function maybeDemoteAnchorDrift(
attempt: ClaimedMemoryUpdateAttempt,
installation: RepoInstallation,
config: GreplicaConfig,
): Promise<void> {
const checkout = inspectAnchorDriftCheckout(attempt.session.cwd, installation.defaultBranch);
if (!checkout.eligible) return;

const repo = {
repo_root: checkout.repoRoot,
remote_url: installation.remoteUrl,
repo_name: installation.repoName,
default_branch: installation.defaultBranch,
} satisfies RepoRef;

try {
const provider = createGraphMemoryProvider(repo, config);
try {
await runAnchorDriftPass(provider, checkout.gitHead);
} finally {
provider.close();
}
} catch {
// Background drift checks must not block transcript memory updates. A later due run retries.
}
}

async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Promise<void> {
const cwd = attempt.session.cwd;
const transcriptPath = attempt.session.transcript_path;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs",
"smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs",
"smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-anchor-drift-worker.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js",
"test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js",
"test:reconciliation-code-evidence": "npm run build && node scripts/check-reconciliation-code-evidence.js",
"test:repo-installations": "npm run build && node scripts/check-repo-installations.js",
Expand Down
Loading