From 22f61f80c1de0a188468defcc690df2793174e60 Mon Sep 17 00:00:00 2001 From: Aadivya Raushan <96186374+aadivyaraushan@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:30:53 +0530 Subject: [PATCH 1/6] fix: reject short embeddings, stop guessing main, escape transcript tags (C1/C4/C6) Part C correctness from #145: fail closed on incomplete embedBatch results, resolve default branch via ls-remote or unknown, and neutralize early-close tags in working-memory prompts so pasted transcript content cannot break the evidence fence. Co-authored-by: Cursor --- apps/cli/repo-context.ts | 12 +- libs/hooks/worker.ts | 13 +- .../graph-context/context-builder.ts | 46 ++++- package.json | 2 +- scripts/check-embed-batch-length.js | 171 ++++++++++++++++++ scripts/check-repo-context.js | 27 ++- scripts/check-worker-transcript-escape.js | 41 +++++ 7 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 scripts/check-embed-batch-length.js create mode 100644 scripts/check-worker-transcript-escape.js diff --git a/apps/cli/repo-context.ts b/apps/cli/repo-context.ts index c74716f..f282d71 100644 --- a/apps/cli/repo-context.ts +++ b/apps/cli/repo-context.ts @@ -39,7 +39,17 @@ function defaultBranch(repoRoot: string): string { const remoteHead = gitOptional(repoRoot, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]); if (remoteHead?.startsWith("origin/")) return remoteHead.slice("origin/".length); - return "main"; + const lsRemoteSymref = gitOptional(repoRoot, ["ls-remote", "--symref", "origin", "HEAD"]); + const fromLsRemote = parseLsRemoteSymrefHead(lsRemoteSymref); + if (fromLsRemote !== undefined) return fromLsRemote; + + return "unknown"; +} + +function parseLsRemoteSymrefHead(output: string | undefined): string | undefined { + if (output === undefined) return undefined; + const match = output.match(/^ref:\s+refs\/heads\/([^\s\t]+)/m); + return match?.[1]; } function repoName(remoteUrl: string, repoRoot: string): string { diff --git a/libs/hooks/worker.ts b/libs/hooks/worker.ts index 6b9d245..fe0a720 100644 --- a/libs/hooks/worker.ts +++ b/libs/hooks/worker.ts @@ -102,12 +102,13 @@ async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Pr } } -function updateWorkingMemoryPrompt( +export function updateWorkingMemoryPrompt( transcriptMarkdown: string, attempt: ClaimedMemoryUpdateAttempt, sessionRef: string, proposalPath: string, ): string { + const escapedTranscript = escapeFilteredSessionTranscript(transcriptMarkdown.trim()); return `Run the greplica-update-working-memory skill for a completed coding-agent session. If your runtime supports slash-command skills, invoke /greplica-update-working-memory for this task. Use the filtered session transcript below as the session context. It has been projected to Markdown with session metadata and human/agent text messages only. @@ -128,11 +129,19 @@ Session: - due_reason: ${attempt.reason} -${transcriptMarkdown.trim()} +${escapedTranscript} `; } +/** Neutralize literal closing tags so pasted transcript content cannot break the evidence fence. */ +function escapeFilteredSessionTranscript(transcriptMarkdown: string): string { + return transcriptMarkdown.replaceAll( + "", + "", + ); +} + function safePathSegment(value: string): string { return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "unknown"; } diff --git a/libs/knowledge-graph/graph-context/context-builder.ts b/libs/knowledge-graph/graph-context/context-builder.ts index 0a43b02..49aae38 100644 --- a/libs/knowledge-graph/graph-context/context-builder.ts +++ b/libs/knowledge-graph/graph-context/context-builder.ts @@ -25,6 +25,8 @@ export interface BuildGraphContextOptions { config?: GraphContextConfig; repoRoot?: string; resolveCodeAnchors?: boolean; + /** Optional embedder override (tests / hermetic callers). */ + embedder?: Embedder; } interface ExistingEmbedding { @@ -44,7 +46,7 @@ export class GraphContextBuilder { const flowDocuments = buildFlowDocuments(graph); const evidenceByClaim = buildEvidenceByClaim(graph); const documents = [...claimDocuments, ...componentDocuments, ...flowDocuments]; - const embedder = createEmbedder(config.embedding); + const embedder = options.embedder ?? createEmbedder(config.embedding); const embeddingStatus = await this.ensureEmbeddings(repoId, documents, embedder, config); if (options.warnOnCreatedEmbeddings && embeddingStatus.created > 0) { console.warn(`graph context created ${embeddingStatus.created} missing embedding(s); proposal apply should normally pre-create them.`); @@ -120,14 +122,23 @@ export class GraphContextBuilder { }; } - async ensureForGraph(repoId: string, graph: GraphReadResult, config: GraphContextConfig = graphContextConfig): Promise { + async ensureForGraph( + repoId: string, + graph: GraphReadResult, + config: GraphContextConfig = graphContextConfig, + embedder?: Embedder, + ): Promise { const documents = [ ...buildClaimDocuments(graph), ...buildComponentDocuments(graph), ...buildFlowDocuments(graph), ]; - const embedder = createEmbedder(config.embedding); - return this.ensureEmbeddings(repoId, documents, embedder, config); + return this.ensureEmbeddings( + repoId, + documents, + embedder ?? createEmbedder(config.embedding), + config, + ); } private async ensureEmbeddings( @@ -149,6 +160,7 @@ export class GraphContextBuilder { ); const missing = documents.filter((document) => !existing.has(document.key)); const vectors = await embedder.embedBatch(missing.map((document) => document.text)); + assertEmbedBatchComplete(vectors, missing.length, config.embedding.dimensions); repository.insertGraphObjectEmbeddings( missing.map((document, index) => ({ @@ -158,7 +170,7 @@ export class GraphContextBuilder { provider: config.embedding.provider, model: config.embedding.model, dimensions: config.embedding.dimensions, - embedding: float32ArrayToBuffer(vectors[index] ?? []), + embedding: float32ArrayToBuffer(vectors[index]), })), ); @@ -224,6 +236,30 @@ export class GraphContextBuilder { } } +function assertEmbedBatchComplete( + vectors: number[][], + expectedCount: number, + dimensions: number, +): void { + if (vectors.length !== expectedCount) { + throw new Error( + `embedBatch returned ${vectors.length} vector(s); expected ${expectedCount}.`, + ); + } + + for (let index = 0; index < vectors.length; index += 1) { + const vector = vectors[index]; + if (vector === undefined || vector.length === 0) { + throw new Error(`embedBatch returned an empty vector at index ${index}.`); + } + if (vector.length !== dimensions) { + throw new Error( + `embedBatch vector at index ${index} has length ${vector.length}; expected ${dimensions}.`, + ); + } + } +} + function buildEvidenceByClaim(graph: GraphReadResult): Map { const sources = new Map(graph.sources.map((source) => [source.id, source])); const evidenceByClaim = new Map(); diff --git a/package.json b/package.json index 4c46f31..c236e7d 100644 --- a/package.json +++ b/package.json @@ -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-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", + "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-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-embed-batch-length.js && node scripts/check-worker-transcript-escape.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", diff --git a/scripts/check-embed-batch-length.js b/scripts/check-embed-batch-length.js new file mode 100644 index 0000000..56eb21c --- /dev/null +++ b/scripts/check-embed-batch-length.js @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root)); +const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root)); +const { GraphContextBuilder } = await import( + new URL("dist/libs/knowledge-graph/graph-context/context-builder.js", root) +); + +const tmp = mkdtempSync(join(tmpdir(), "greplica-embed-batch-")); +const db = openDatabase(join(tmp, "graph.db")); +const repository = new SqliteRepository(db); +const builder = new GraphContextBuilder(repository); + +const repo = { + repo_root: tmp, + repo_name: "embed-batch-check", + default_branch: "main", +}; +const { id: repoId } = repository.upsertRepo(repo).repo; + +const dimensions = 8; +const config = { + version: "test-embed-batch", + embedding: { + provider: "local", + model: "test-model", + dimensions, + batchSize: 16, + }, + ranking: { + semanticThreshold: 0.1, + selectionThreshold: 0.8, + packetMinimumScore: 0.15, + packetAdditionalDirectScoreFloor: 0.15, + minimumSelectedClaims: 1, + weights: { semantic: 1, bm25: 0 }, + bm25: { k1: 1.5, b: 0.75 }, + claimSupport: { weight: 0, countBoost: 0 }, + directObject: { weight: 0 }, + graphBoost: { + claimAboutTarget: 0, + containsParentToChild: 0, + containsChildToParent: 0, + touchesComponentToFlow: 0, + maxSources: 1, + }, + packetHubPenalty: { + weight: 0, + graphScoreThreshold: 0, + claimSupportThreshold: 0, + bm25Threshold: 0, + semanticThreshold: 0, + coherenceThreshold: 0, + }, + coherence: { + weight: 0, + neighborThreshold: 0, + degreePenalty: 0, + aboutWeight: 0, + touchesWeight: 0, + containsWeight: 0, + maxSources: 1, + }, + }, + dedupe: { similarityThreshold: 0.75 }, +}; + +const graph = { + components: [], + flows: [], + claims: [ + { + id: "claim.one", + kind: "fact", + text: "first claim text for embedding", + truth: "source_verified", + intent: "intended", + }, + { + id: "claim.two", + kind: "fact", + text: "second claim text for embedding", + truth: "source_verified", + intent: "intended", + }, + ], + sources: [], + edges: [], +}; + +const shortBatchEmbedder = { + async embed() { + return Array.from({ length: dimensions }, () => 0.1); + }, + async embedBatch(texts) { + // Deliberately return fewer vectors than texts. + return texts.slice(0, Math.max(0, texts.length - 1)).map(() => Array.from({ length: dimensions }, () => 0.2)); + }, +}; + +await assert.rejects( + () => builder.ensureForGraph(repoId, graph, config, shortBatchEmbedder), + (error) => { + assert.ok(error instanceof Error); + assert.match(error.message, /embedBatch|vector/i); + return true; + }, +); + +const rowsAfterShortBatch = repository.listGraphObjectEmbeddings({ + repo_id: repoId, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, +}); +assert.equal(rowsAfterShortBatch.length, 0, "short embedBatch must not insert embedding rows"); + +const emptyVectorEmbedder = { + async embed() { + return Array.from({ length: dimensions }, () => 0.1); + }, + async embedBatch(texts) { + return texts.map(() => []); + }, +}; + +await assert.rejects( + () => builder.ensureForGraph(repoId, graph, config, emptyVectorEmbedder), + (error) => { + assert.ok(error instanceof Error); + assert.match(error.message, /empty|length|dimension|vector/i); + return true; + }, +); + +const rowsAfterEmpty = repository.listGraphObjectEmbeddings({ + repo_id: repoId, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, +}); +assert.equal(rowsAfterEmpty.length, 0, "empty vectors must not insert embedding rows"); + +const healthyEmbedder = { + async embed() { + return Array.from({ length: dimensions }, () => 0.1); + }, + async embedBatch(texts) { + return texts.map((_, index) => Array.from({ length: dimensions }, () => 0.01 * (index + 1))); + }, +}; + +const status = await builder.ensureForGraph(repoId, graph, config, healthyEmbedder); +assert.equal(status.created, 2); +const rowsHealthy = repository.listGraphObjectEmbeddings({ + repo_id: repoId, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, +}); +assert.equal(rowsHealthy.length, 2); +for (const row of rowsHealthy) { + assert.ok(row.embedding.byteLength > 0, "healthy embeddings must be non-empty BLOBs"); +} + +db.close(); +console.log("Embed batch length checks passed."); diff --git a/scripts/check-repo-context.js b/scripts/check-repo-context.js index 410a9eb..482f7ab 100644 --- a/scripts/check-repo-context.js +++ b/scripts/check-repo-context.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, realpathSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; @@ -37,9 +37,30 @@ const branchContext = detectRepoContext(branchRepo); assert.equal(branchContext.default_branch, "trunk"); const noRemoteHeadRepo = initRepo(join(tmp, "no-remote-head-repo")); -git(noRemoteHeadRepo, "remote", "add", "origin", "https://github.com/Autoloops/greplica.git"); +git(noRemoteHeadRepo, "remote", "add", "origin", `file://${join(tmp, "missing-remote.git")}`); const noRemoteHeadContext = detectRepoContext(noRemoteHeadRepo); -assert.equal(noRemoteHeadContext.default_branch, "main"); +assert.equal(noRemoteHeadContext.default_branch, "unknown"); + +const bareOrigin = join(tmp, "bare-origin.git"); +git(tmp, "init", "--bare", "--quiet", bareOrigin); +git(bareOrigin, "symbolic-ref", "HEAD", "refs/heads/develop"); +const commitRepo = initRepo(join(tmp, "commit-for-bare")); +writeFileSync(join(commitRepo, "README"), "develop tip\n"); +git(commitRepo, "add", "README"); +git(commitRepo, "-c", "user.name=Greplica Test", "-c", "user.email=test@example.com", "commit", "-m", "init"); +git(commitRepo, "branch", "-M", "develop"); +git(commitRepo, "remote", "add", "origin", bareOrigin); +git(commitRepo, "push", "--quiet", "origin", "develop"); +git(bareOrigin, "symbolic-ref", "HEAD", "refs/heads/develop"); + +const lsRemoteRepo = initRepo(join(tmp, "ls-remote-default-repo")); +git(lsRemoteRepo, "remote", "add", "origin", bareOrigin); +const lsRemoteContext = detectRepoContext(lsRemoteRepo); +assert.equal( + lsRemoteContext.default_branch, + "develop", + "when origin/HEAD is missing, ls-remote --symref should resolve the remote default branch", +); console.log("Repo context checks passed."); diff --git a/scripts/check-worker-transcript-escape.js b/scripts/check-worker-transcript-escape.js new file mode 100644 index 0000000..b42f783 --- /dev/null +++ b/scripts/check-worker-transcript-escape.js @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; + +const root = new URL("..", import.meta.url); +const { updateWorkingMemoryPrompt } = await import(new URL("dist/libs/hooks/worker.js", root)); + +const earlyClose = "before after hostile content"; +const attempt = { + reason: "test", + session: { + platform: "cursor", + session_id: "sess-escape-1", + }, +}; +const prompt = updateWorkingMemoryPrompt(earlyClose, attempt, "cursor:sess-escape-1", "/tmp/proposal.json"); + +assert.ok(prompt.includes(""), "opening tag must remain"); +assert.ok(prompt.includes(""), "real closing tag must remain"); + +const openTag = ""; +const closeTag = ""; +const openIndex = prompt.indexOf(openTag); +const closeIndex = prompt.lastIndexOf(closeTag); +assert.ok(openIndex >= 0); +assert.ok(closeIndex > openIndex); + +const inner = prompt.slice(openIndex + openTag.length, closeIndex); +assert.equal( + inner.includes(closeTag), + false, + "transcript body must not contain a raw early-close sequence", +); +assert.ok( + inner.includes("before") && inner.includes("after hostile content"), + "escaped transcript should still preserve surrounding text", +); + +// Only one structural close tag for the evidence fence (the real closing tag). +const closeMatches = prompt.match(/<\/filtered_session_transcript>/g) ?? []; +assert.equal(closeMatches.length, 1, "exactly one raw closing tag should remain in the prompt"); + +console.log("Worker transcript escape checks passed."); From 3f7d0d0576df77de878554d6a23effd17b62419b Mon Sep 17 00:00:00 2001 From: Aadivya Raushan <96186374+aadivyaraushan@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:33:09 +0530 Subject: [PATCH 2/6] Fix hook worker failure trails and pin GREPLICA_HOME (C2, C3). Log hook-worker failures to JSONL and retain run artifacts; resolve and pin GREPLICA_HOME into child env and surface it in doctor. Co-authored-by: Cursor --- apps/cli/main.ts | 5 ++ libs/config/greplica-home.ts | 19 ++++- libs/hooks/worker.ts | 111 +++++++++++++++++++++++--- package.json | 2 +- scripts/check-greplica-home-pin.js | 48 +++++++++++ scripts/check-hook-worker-failures.js | 82 +++++++++++++++++++ 6 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 scripts/check-greplica-home-pin.js create mode 100644 scripts/check-hook-worker-failures.js diff --git a/apps/cli/main.ts b/apps/cli/main.ts index a1524bb..588ae23 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -12,6 +12,7 @@ import { type EmbeddingConfig, type GreplicaConfig, } from "../../libs/config/greplica-config.js"; +import { resolveGreplicaHome } from "../../libs/config/greplica-home.js"; import { createGraphMemoryProvider } from "../../libs/knowledge-graph/provider-factory.js"; import type { GraphMemoryProvider } from "../../libs/knowledge-graph/provider.js"; import { createEmbedder } from "../../libs/knowledge-graph/graph-context/embedder.js"; @@ -683,6 +684,10 @@ async function runDoctor(args: string[], getContext: CommandContextProvider): Pr console.log(`Remote: ${context.repo.remote_url ?? "none"}`); console.log(`Default branch: ${context.repo.default_branch}`); + const greplicaHomeInfo = resolveGreplicaHome(); + console.log(`Greplica home: ${resolve(greplicaHomeInfo.path)}`); + console.log(`Greplica home source: ${greplicaHomeInfo.source}`); + const installation = context.service.installation; console.log(`Database: ${resolve(defaultDatabasePath())}`); console.log("Memory state: ready"); diff --git a/libs/config/greplica-home.ts b/libs/config/greplica-home.ts index 49825b9..5fa8050 100644 --- a/libs/config/greplica-home.ts +++ b/libs/config/greplica-home.ts @@ -1,6 +1,23 @@ import { homedir } from "node:os"; import { join } from "node:path"; +export type GreplicaHomeSource = "GREPLICA_HOME" | "ENGINEERING_CONTEXT_HOME" | "default"; + +export interface ResolvedGreplicaHome { + path: string; + source: GreplicaHomeSource; +} + +export function resolveGreplicaHome(env: NodeJS.ProcessEnv = process.env): ResolvedGreplicaHome { + if (env.GREPLICA_HOME !== undefined && env.GREPLICA_HOME.length > 0) { + return { path: env.GREPLICA_HOME, source: "GREPLICA_HOME" }; + } + if (env.ENGINEERING_CONTEXT_HOME !== undefined && env.ENGINEERING_CONTEXT_HOME.length > 0) { + return { path: env.ENGINEERING_CONTEXT_HOME, source: "ENGINEERING_CONTEXT_HOME" }; + } + return { path: join(homedir(), ".greplica"), source: "default" }; +} + export function greplicaHome(): string { - return process.env.GREPLICA_HOME ?? process.env.ENGINEERING_CONTEXT_HOME ?? join(homedir(), ".greplica"); + return resolveGreplicaHome().path; } diff --git a/libs/hooks/worker.ts b/libs/hooks/worker.ts index 6b9d245..62a692c 100644 --- a/libs/hooks/worker.ts +++ b/libs/hooks/worker.ts @@ -1,17 +1,39 @@ import { spawn } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + appendFileSync, + cpSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ClaimedMemoryUpdateAttempt } from "./types.js"; import { LocalAgentRuntimeStore } from "./runtime-store.js"; import { WorkerLease } from "../utils/worker-lease.js"; import { ensureGreplicaConfig } from "../config/greplica-config.js"; +import { resolveGreplicaHome } from "../config/greplica-home.js"; import { canScheduleMemoryUpdates, type RepoInstallation } from "../install/repo-installation-store.js"; import { platformInstaller } from "../install/platforms/index.js"; import { openDatabase } from "../storage/sqlite/db.js"; const hookWorkerLockName = "hook-memory-update-worker"; const hookWorkerHeartbeatMs = 60 * 1000; +const retainedHookRunLimit = 20; + +export function hookWorkerChildEnv( + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + // Always resolve from the live parent process.env, then pin GREPLICA_HOME into + // the child env so a stripped/partial baseEnv cannot fall back to ~/.greplica. + const { path } = resolveGreplicaHome(process.env); + return { + ...baseEnv, + GREPLICA_HOME: path, + }; +} export function startHookWorker(): void { const script = process.argv[1]; @@ -21,11 +43,16 @@ export function startHookWorker(): void { const child = spawn(process.execPath, [script, "hook", "worker"], { detached: true, stdio: "ignore", - env: process.env, + env: hookWorkerChildEnv(), }); child.unref(); - } catch { - // Hooks must stay best-effort and fast. A later hook can try again. + } catch (error: unknown) { + appendHookWorkerFailureLog({ + platform: null, + session_id: null, + phase: "spawn_worker", + error: errorMessage(error), + }); } } @@ -62,7 +89,7 @@ export async function runHookWorker(): Promise { } } -async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Promise { +export async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Promise { const cwd = attempt.session.cwd; const transcriptPath = attempt.session.transcript_path; if (cwd === null || transcriptPath === null) return; @@ -83,22 +110,32 @@ async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Pr join(tmpdir(), `greplica-hook-${safePathSegment(attempt.session.platform)}-${safePathSegment(attempt.session.session_id)}-`), ); const proposalPath = join(runDir, "working-memory.proposal.json"); + let succeeded = false; try { await runner.runWorkingMemoryUpdate({ cwd, env: { - ...process.env, + ...hookWorkerChildEnv(), GREPLICA_HOOK_DISABLE: "1", }, prompt: updateWorkingMemoryPrompt(transcriptMarkdown, attempt, sessionRef, proposalPath), transcriptPath: join(runDir, "agent-events.jsonl"), finalMessagePath: join(runDir, "final-message.md"), }); - } catch { - // Failed background updates should not affect foreground hook sessions. + succeeded = true; + } catch (error: unknown) { + appendHookWorkerFailureLog({ + platform: attempt.session.platform, + session_id: attempt.session.session_id, + phase: "working_memory_update", + error: errorMessage(error), + }); + retainFailedHookRun(runDir, attempt); } finally { - rmSync(runDir, { recursive: true, force: true }); + if (succeeded) { + rmSync(runDir, { recursive: true, force: true }); + } } } @@ -133,6 +170,62 @@ ${transcriptMarkdown.trim()} `; } +export function appendHookWorkerFailureLog(entry: { + platform: string | null; + session_id: string | null; + phase: string; + error: string; +}): void { + try { + const home = resolveGreplicaHome().path; + const logsDir = join(home, "logs"); + mkdirSync(logsDir, { recursive: true }); + const line = JSON.stringify({ + timestamp: new Date().toISOString(), + platform: entry.platform, + session_id: entry.session_id, + phase: entry.phase, + error: entry.error, + }); + appendFileSync(join(logsDir, "hook-worker.jsonl"), `${line}\n`, "utf8"); + } catch { + // Logging is best-effort; never throw from the failure path. + } +} + +function retainFailedHookRun(runDir: string, attempt: ClaimedMemoryUpdateAttempt): void { + try { + const home = resolveGreplicaHome().path; + const runsDir = join(home, "logs", "runs"); + mkdirSync(runsDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const retainedName = `${stamp}-${safePathSegment(attempt.session.platform)}-${safePathSegment(attempt.session.session_id)}`; + const destination = join(runsDir, retainedName); + // Copy (do not delete runDir): agent close handlers may still write into the + // original temp path after spawn failure rejects the update promise. + cpSync(runDir, destination, { recursive: true }); + pruneRetainedHookRuns(runsDir); + } catch { + // If retention fails, leave runDir in place (skip cleanup) for inspection. + } +} + +function pruneRetainedHookRuns(runsDir: string): void { + const entries = readdirSync(runsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + const excess = entries.length - retainedHookRunLimit; + if (excess <= 0) return; + for (const name of entries.slice(0, excess)) { + rmSync(join(runsDir, name), { recursive: true, force: true }); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function safePathSegment(value: string): string { return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "unknown"; } diff --git a/package.json b/package.json index 4c46f31..e256af5 100644 --- a/package.json +++ b/package.json @@ -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-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", + "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-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-greplica-home-pin.js && node scripts/check-hook-worker-failures.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", diff --git a/scripts/check-greplica-home-pin.js b/scripts/check-greplica-home-pin.js new file mode 100644 index 0000000..11621e8 --- /dev/null +++ b/scripts/check-greplica-home-pin.js @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const savedHome = process.env.GREPLICA_HOME; +const savedEngineering = process.env.ENGINEERING_CONTEXT_HOME; + +try { + delete process.env.GREPLICA_HOME; + delete process.env.ENGINEERING_CONTEXT_HOME; + + const { resolveGreplicaHome, greplicaHome } = await import(new URL("dist/libs/config/greplica-home.js", root)); + const { hookWorkerChildEnv } = await import(new URL("dist/libs/hooks/worker.js", root)); + + const defaultResolved = resolveGreplicaHome(); + assert.equal(defaultResolved.source, "default"); + assert.equal(defaultResolved.path, join(homedir(), ".greplica")); + assert.equal(greplicaHome(), defaultResolved.path); + + process.env.ENGINEERING_CONTEXT_HOME = "/tmp/engineering-context-home-pin-test"; + const engineeringResolved = resolveGreplicaHome(); + assert.equal(engineeringResolved.source, "ENGINEERING_CONTEXT_HOME"); + assert.equal(engineeringResolved.path, "/tmp/engineering-context-home-pin-test"); + assert.equal(greplicaHome(), engineeringResolved.path); + + process.env.GREPLICA_HOME = "/tmp/greplica-home-pin-test"; + const envResolved = resolveGreplicaHome(); + assert.equal(envResolved.source, "GREPLICA_HOME"); + assert.equal(envResolved.path, "/tmp/greplica-home-pin-test"); + assert.equal(greplicaHome(), envResolved.path); + + const childEnv = hookWorkerChildEnv(); + assert.equal(childEnv.GREPLICA_HOME, "/tmp/greplica-home-pin-test"); + assert.equal(childEnv.GREPLICA_HOME, resolveGreplicaHome().path); + + // Partial base env (no GREPLICA_HOME) must still pin the parent's resolved home. + const strippedChildEnv = hookWorkerChildEnv({ PATH: "/usr/bin" }); + assert.equal(strippedChildEnv.GREPLICA_HOME, "/tmp/greplica-home-pin-test"); + assert.equal(strippedChildEnv.PATH, "/usr/bin"); + + console.log("check-greplica-home-pin: ok"); +} finally { + if (savedHome === undefined) delete process.env.GREPLICA_HOME; + else process.env.GREPLICA_HOME = savedHome; + if (savedEngineering === undefined) delete process.env.ENGINEERING_CONTEXT_HOME; + else process.env.ENGINEERING_CONTEXT_HOME = savedEngineering; +} diff --git a/scripts/check-hook-worker-failures.js b/scripts/check-hook-worker-failures.js new file mode 100644 index 0000000..34f2f2a --- /dev/null +++ b/scripts/check-hook-worker-failures.js @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const temporary = mkdtempSync(join(tmpdir(), "greplica-hook-worker-failures-")); +const greplicaHomeDir = join(temporary, "greplica-home"); +const workspace = join(temporary, "repo"); +const transcriptPath = join(temporary, "session.jsonl"); +const savedHome = process.env.GREPLICA_HOME; +const savedPath = process.env.PATH; + +mkdirSync(greplicaHomeDir, { recursive: true }); +mkdirSync(workspace, { recursive: true }); +writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: "user", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "hello from hook failure test" }] }, + })}\n`, + "utf8", +); + +process.env.GREPLICA_HOME = greplicaHomeDir; +// Force spawn("claude") to fail so runWorkingMemoryUpdate rejects. +process.env.PATH = join(temporary, "empty-bin"); + +try { + const { maybeUpdateWorkingMemory } = await import(new URL("dist/libs/hooks/worker.js", root)); + + await maybeUpdateWorkingMemory({ + reason: "stop_threshold", + session: { + platform: "claude", + session_id: "hook-failure-session", + repo_id: "repo-test", + transcript_path: transcriptPath, + cwd: workspace, + guidance_injected_at: null, + stops_since_memory_current: 1, + last_seen_at: new Date().toISOString(), + last_memory_current_at: null, + }, + }); + + const logPath = join(greplicaHomeDir, "logs", "hook-worker.jsonl"); + assert.equal(existsSync(logPath), true, "expected hook-worker.jsonl after failure"); + const lines = readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); + assert.equal(lines.length >= 1, true, "expected at least one failure log line"); + const entry = JSON.parse(lines.at(-1)); + assert.equal(entry.platform, "claude"); + assert.equal(entry.session_id, "hook-failure-session"); + assert.equal(typeof entry.phase, "string"); + assert.equal(typeof entry.error, "string"); + assert.ok(entry.error.length > 0, "error message should be non-empty"); + assert.equal(typeof entry.timestamp, "string"); + + const runsDir = join(greplicaHomeDir, "logs", "runs"); + assert.equal(existsSync(runsDir), true, "expected retained run artifacts under logs/runs"); + const retained = readdirSync(runsDir); + assert.equal(retained.length >= 1, true, "expected at least one retained run directory"); + const retainedPath = join(runsDir, retained[0]); + assert.equal(existsSync(retainedPath), true); + + console.log("check-hook-worker-failures: ok"); +} finally { + if (savedHome === undefined) delete process.env.GREPLICA_HOME; + else process.env.GREPLICA_HOME = savedHome; + if (savedPath === undefined) delete process.env.PATH; + else process.env.PATH = savedPath; + rmSync(temporary, { recursive: true, force: true }); +} From d8d6ae5dd3f7311d1f801fc47b9a283d3733156d Mon Sep 17 00:00:00 2001 From: Aadivya Raushan <96186374+aadivyaraushan@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:30:18 +0530 Subject: [PATCH 3/6] fix: harden C4 timeout/scopes and cover wrong-dimension embeddings Add a 5s timeout on ls-remote, reuse/rename the existing main scope when default_branch changes, and reject wrong-length embedding vectors in tests. Co-authored-by: Cursor --- apps/cli/repo-context.ts | 12 ++++++-- libs/storage/sqlite/repository.ts | 20 ++++++++++++ package.json | 2 +- scripts/check-embed-batch-length.js | 26 ++++++++++++++++ scripts/check-main-scope-reuse.js | 47 +++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 scripts/check-main-scope-reuse.js diff --git a/apps/cli/repo-context.ts b/apps/cli/repo-context.ts index f282d71..d2ecf0e 100644 --- a/apps/cli/repo-context.ts +++ b/apps/cli/repo-context.ts @@ -39,7 +39,10 @@ function defaultBranch(repoRoot: string): string { const remoteHead = gitOptional(repoRoot, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]); if (remoteHead?.startsWith("origin/")) return remoteHead.slice("origin/".length); - const lsRemoteSymref = gitOptional(repoRoot, ["ls-remote", "--symref", "origin", "HEAD"]); + // Network call — keep a short timeout so install/doctor/graph cannot hang on a bad remote. + const lsRemoteSymref = gitOptional(repoRoot, ["ls-remote", "--symref", "origin", "HEAD"], { + timeoutMs: 5_000, + }); const fromLsRemote = parseLsRemoteSymrefHead(lsRemoteSymref); if (fromLsRemote !== undefined) return fromLsRemote; @@ -58,12 +61,17 @@ function repoName(remoteUrl: string, repoRoot: string): string { return lastPart ?? basename(repoRoot); } -function gitOptional(cwd: string, args: string[]): string | undefined { +function gitOptional( + cwd: string, + args: string[], + options: { timeoutMs?: number } = {}, +): string | undefined { try { const output = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + ...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), }).trim(); return output.length > 0 ? output : undefined; } catch { diff --git a/libs/storage/sqlite/repository.ts b/libs/storage/sqlite/repository.ts index 2b43f28..498c9f3 100644 --- a/libs/storage/sqlite/repository.ts +++ b/libs/storage/sqlite/repository.ts @@ -224,6 +224,26 @@ export class SqliteRepository implements GraphReadRepository { } ensureScope(input: CreateScopeInput): GraphScope { + // One main scope per repo: reuse and rename/ref-update if default_branch changes + // (e.g. fabricated "main" → "unknown" or ls-remote-resolved name) instead of + // creating orphan kind=main rows keyed by the new branch string. + if (input.kind === "main") { + const existingMain = this.db + .prepare( + "SELECT * FROM graph_scopes WHERE repo_id = ? AND kind = 'main' ORDER BY created_at LIMIT 1", + ) + .get(input.repo_id) as GraphScope | undefined; + if (existingMain) { + if (existingMain.name !== input.name || existingMain.ref !== input.ref) { + this.db + .prepare("UPDATE graph_scopes SET name = ?, ref = ? WHERE id = ?") + .run(input.name, input.ref, existingMain.id); + return { ...existingMain, name: input.name, ref: input.ref }; + } + return existingMain; + } + } + const existing = this.db .prepare("SELECT * FROM graph_scopes WHERE repo_id = ? AND kind = ? AND name = ?") .get(input.repo_id, input.kind, input.name) as GraphScope | undefined; diff --git a/package.json b/package.json index c236e7d..a117fcb 100644 --- a/package.json +++ b/package.json @@ -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-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-embed-batch-length.js && node scripts/check-worker-transcript-escape.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-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-embed-batch-length.js && node scripts/check-worker-transcript-escape.js && node scripts/check-main-scope-reuse.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", diff --git a/scripts/check-embed-batch-length.js b/scripts/check-embed-batch-length.js index 56eb21c..c9aa203 100644 --- a/scripts/check-embed-batch-length.js +++ b/scripts/check-embed-batch-length.js @@ -145,6 +145,32 @@ const rowsAfterEmpty = repository.listGraphObjectEmbeddings({ }); assert.equal(rowsAfterEmpty.length, 0, "empty vectors must not insert embedding rows"); +const wrongDimensionEmbedder = { + async embed() { + return Array.from({ length: dimensions }, () => 0.1); + }, + async embedBatch(texts) { + return texts.map(() => Array.from({ length: dimensions - 1 }, () => 0.3)); + }, +}; + +await assert.rejects( + () => builder.ensureForGraph(repoId, graph, config, wrongDimensionEmbedder), + (error) => { + assert.ok(error instanceof Error); + assert.match(error.message, /dimension|length|vector/i); + return true; + }, +); + +const rowsAfterWrongDim = repository.listGraphObjectEmbeddings({ + repo_id: repoId, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, +}); +assert.equal(rowsAfterWrongDim.length, 0, "wrong-dimension vectors must not insert embedding rows"); + const healthyEmbedder = { async embed() { return Array.from({ length: dimensions }, () => 0.1); diff --git a/scripts/check-main-scope-reuse.js b/scripts/check-main-scope-reuse.js new file mode 100644 index 0000000..1911e31 --- /dev/null +++ b/scripts/check-main-scope-reuse.js @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Caller: package.json "test". Covers ensureScope main-scope reuse when default_branch changes. +// User: follow-up commits for code-review high/medium/low items. + +const root = new URL("..", import.meta.url); +const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root)); +const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root)); + +const tmp = mkdtempSync(join(tmpdir(), "greplica-main-scope-reuse-")); +const db = openDatabase(join(tmp, "graph.db")); +const repository = new SqliteRepository(db); + +const { repo } = repository.upsertRepo({ + repo_root: tmp, + repo_name: "scope-reuse", + default_branch: "main", +}); + +const mainFirst = repository.ensureScope({ + repo_id: repo.id, + kind: "main", + name: "main", + ref: "main", +}); +assert.equal(mainFirst.name, "main"); + +const mainSecond = repository.ensureScope({ + repo_id: repo.id, + kind: "main", + name: "unknown", + ref: "unknown", +}); +assert.equal(mainSecond.id, mainFirst.id, "must reuse the same main scope id"); +assert.equal(mainSecond.name, "unknown"); +assert.equal(mainSecond.ref, "unknown"); + +const mainCount = db + .prepare("SELECT COUNT(*) AS c FROM graph_scopes WHERE repo_id = ? AND kind = 'main'") + .get(repo.id).c; +assert.equal(mainCount, 1, "re-init with a new default_branch must not create a second main scope"); + +db.close(); +console.log("Main scope reuse checks passed."); From 1d6135aca7862dce8045872a647798e1cf6b3750 Mon Sep 17 00:00:00 2001 From: Aadivya Raushan <96186374+aadivyaraushan@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:30:18 +0530 Subject: [PATCH 4/6] fix: doctor home on failure path, rotate logs, delay temp cleanup Print Greplica home before context resolution, cap hook-worker.jsonl, and delay OS temp runDir removal after retain so spawn close handlers cannot race. Co-authored-by: Cursor --- apps/cli/main.ts | 10 +++---- libs/hooks/worker.ts | 41 +++++++++++++++++++++++---- scripts/check-hook-worker-failures.js | 4 +++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 588ae23..ebfd95d 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -667,6 +667,11 @@ function parseHookPlatform(value: string | undefined): InstallPlatform { } async function runDoctor(args: string[], getContext: CommandContextProvider): Promise { + console.log("Greplica doctor"); + const greplicaHomeInfo = resolveGreplicaHome(); + console.log(`Greplica home: ${resolve(greplicaHomeInfo.path)}`); + console.log(`Greplica home source: ${greplicaHomeInfo.source}`); + let context: CommandContext; try { context = getContext(); @@ -678,16 +683,11 @@ async function runDoctor(args: string[], getContext: CommandContextProvider): Pr } let ready = true; - console.log("Greplica doctor"); console.log(`Repo: ${context.repo.repo_name}`); console.log(`Repo root: ${context.repo.repo_root ?? ""}`); console.log(`Remote: ${context.repo.remote_url ?? "none"}`); console.log(`Default branch: ${context.repo.default_branch}`); - const greplicaHomeInfo = resolveGreplicaHome(); - console.log(`Greplica home: ${resolve(greplicaHomeInfo.path)}`); - console.log(`Greplica home source: ${greplicaHomeInfo.source}`); - const installation = context.service.installation; console.log(`Database: ${resolve(defaultDatabasePath())}`); console.log("Memory state: ready"); diff --git a/libs/hooks/worker.ts b/libs/hooks/worker.ts index 62a692c..cc40369 100644 --- a/libs/hooks/worker.ts +++ b/libs/hooks/worker.ts @@ -7,6 +7,7 @@ import { readdirSync, readFileSync, rmSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -22,6 +23,7 @@ import { openDatabase } from "../storage/sqlite/db.js"; const hookWorkerLockName = "hook-memory-update-worker"; const hookWorkerHeartbeatMs = 60 * 1000; const retainedHookRunLimit = 20; +const hookWorkerLogLineLimit = 500; export function hookWorkerChildEnv( baseEnv: NodeJS.ProcessEnv = process.env, @@ -111,6 +113,7 @@ export async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttem ); const proposalPath = join(runDir, "working-memory.proposal.json"); let succeeded = false; + let retained = false; try { await runner.runWorkingMemoryUpdate({ @@ -131,10 +134,21 @@ export async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttem phase: "working_memory_update", error: errorMessage(error), }); - retainFailedHookRun(runDir, attempt); + retained = retainFailedHookRun(runDir, attempt); } finally { if (succeeded) { rmSync(runDir, { recursive: true, force: true }); + } else if (retained) { + // Agent close handlers may still write into runDir after spawn failure rejects. + // Keep the OS temp copy briefly after retaining under $GREPLICA_HOME/logs/runs. + const timer = setTimeout(() => { + try { + rmSync(runDir, { recursive: true, force: true }); + } catch { + // Best-effort delayed cleanup. + } + }, 15_000); + timer.unref(); } } } @@ -180,6 +194,7 @@ export function appendHookWorkerFailureLog(entry: { const home = resolveGreplicaHome().path; const logsDir = join(home, "logs"); mkdirSync(logsDir, { recursive: true }); + const logPath = join(logsDir, "hook-worker.jsonl"); const line = JSON.stringify({ timestamp: new Date().toISOString(), platform: entry.platform, @@ -187,13 +202,27 @@ export function appendHookWorkerFailureLog(entry: { phase: entry.phase, error: entry.error, }); - appendFileSync(join(logsDir, "hook-worker.jsonl"), `${line}\n`, "utf8"); + appendFileSync(logPath, `${line}\n`, "utf8"); + pruneHookWorkerFailureLog(logPath); } catch { // Logging is best-effort; never throw from the failure path. } } -function retainFailedHookRun(runDir: string, attempt: ClaimedMemoryUpdateAttempt): void { +function pruneHookWorkerFailureLog(logPath: string): void { + try { + const lines = readFileSync(logPath, "utf8").split("\n"); + // Keep trailing empty line after the last JSON object when present. + const nonempty = lines.filter((line) => line.length > 0); + if (nonempty.length <= hookWorkerLogLineLimit) return; + const kept = nonempty.slice(-hookWorkerLogLineLimit); + writeFileSync(logPath, `${kept.join("\n")}\n`, "utf8"); + } catch { + // Best-effort rotation. + } +} + +function retainFailedHookRun(runDir: string, attempt: ClaimedMemoryUpdateAttempt): boolean { try { const home = resolveGreplicaHome().path; const runsDir = join(home, "logs", "runs"); @@ -201,12 +230,14 @@ function retainFailedHookRun(runDir: string, attempt: ClaimedMemoryUpdateAttempt const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const retainedName = `${stamp}-${safePathSegment(attempt.session.platform)}-${safePathSegment(attempt.session.session_id)}`; const destination = join(runsDir, retainedName); - // Copy (do not delete runDir): agent close handlers may still write into the - // original temp path after spawn failure rejects the update promise. + // May contain agent transcript/event output — retained briefly for debugging under + // $GREPLICA_HOME/logs/runs (capped by pruneRetainedHookRuns). Treat as sensitive. cpSync(runDir, destination, { recursive: true }); pruneRetainedHookRuns(runsDir); + return true; } catch { // If retention fails, leave runDir in place (skip cleanup) for inspection. + return false; } } diff --git a/scripts/check-hook-worker-failures.js b/scripts/check-hook-worker-failures.js index 34f2f2a..728c94f 100644 --- a/scripts/check-hook-worker-failures.js +++ b/scripts/check-hook-worker-failures.js @@ -53,6 +53,10 @@ try { }, }); + // Allow platform spawn close handlers to finish writing into the temp runDir + // before assertions (retention copies first; OS temp cleanup is delayed). + await new Promise((resolve) => setTimeout(resolve, 250)); + const logPath = join(greplicaHomeDir, "logs", "hook-worker.jsonl"); assert.equal(existsSync(logPath), true, "expected hook-worker.jsonl after failure"); const lines = readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); From 4933df6bf3e5915b95bd374da8a0fb15835d4e58 Mon Sep 17 00:00:00 2001 From: Aadivya Raushan <96186374+aadivyaraushan@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:35:21 +0530 Subject: [PATCH 5/6] fix: await temp runDir cleanup and document sensitive hook logs Keep the worker alive briefly after retain so OS temp dirs are actually removed, and note that logs/runs may contain sensitive agent output. Co-authored-by: Cursor --- libs/hooks/worker.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/libs/hooks/worker.ts b/libs/hooks/worker.ts index ea12413..523e677 100644 --- a/libs/hooks/worker.ts +++ b/libs/hooks/worker.ts @@ -140,15 +140,14 @@ export async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttem rmSync(runDir, { recursive: true, force: true }); } else if (retained) { // Agent close handlers may still write into runDir after spawn failure rejects. - // Keep the OS temp copy briefly after retaining under $GREPLICA_HOME/logs/runs. - const timer = setTimeout(() => { - try { - rmSync(runDir, { recursive: true, force: true }); - } catch { - // Best-effort delayed cleanup. - } - }, 15_000); - timer.unref(); + // Wait briefly (keeping the process alive), then remove the OS temp copy now that + // a copy exists under $GREPLICA_HOME/logs/runs. + await new Promise((resolve) => setTimeout(resolve, 1_000)); + try { + rmSync(runDir, { recursive: true, force: true }); + } catch { + // Best-effort; leave for inspection if still locked. + } } } } From 32c5618c17ed2c2ce4718399c56b615aa75e1c69 Mon Sep 17 00:00:00 2001 From: Aadivya Raushan <96186374+aadivyaraushan@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:35:35 +0530 Subject: [PATCH 6/6] docs: note sensitive hook failure logs under GREPLICA_HOME Document that logs/runs and hook-worker.jsonl may contain agent output. Co-authored-by: Cursor --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2447d66..f7ace30 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,8 @@ greplica transcript bundle --platform codex|claude|copilot|opencode --file