{"record":{"id":"aac414a1de97ec55","repo":"abhigyanpatwari/GitNexus","slug":"plan-artifact-changed-while-hashing-path","errorCode":null,"errorMessage":"plan artifact changed while hashing: {path}","messagePattern":"plan artifact changed while hashing: (.+?)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"eval/workflow_bench/runner_artifacts.py","lineNumber":281,"sourceCode":"    snapshot: dict[Path, str] = {}\n    for path in sorted(plans.iterdir()):\n        if path.suffix.lower() not in {\".md\", \".html\"}:\n            continue\n        metadata = path.lstat()\n        if stat.S_ISLNK(metadata.st_mode):\n            raise ValueError(f\"plan artifact cannot be a symlink: {path}\")\n        if not stat.S_ISREG(metadata.st_mode):\n            raise ValueError(f\"plan artifact must be a regular file: {path}\")\n        descriptor = os.open(path, os.O_RDONLY | getattr(os, \"O_NOFOLLOW\", 0))\n        try:\n            opened = os.fstat(descriptor)\n            if not stat.S_ISREG(opened.st_mode) or opened.st_dev != metadata.st_dev or opened.st_ino != metadata.st_ino:\n                raise ValueError(f\"plan artifact changed while opening: {path}\")\n            with os.fdopen(descriptor, \"rb\", closefd=False) as handle:\n                snapshot[path] = hashlib.file_digest(handle, \"sha256\").hexdigest()\n            after = os.fstat(descriptor)\n            if (opened.st_size, opened.st_mtime_ns) != (after.st_size, after.st_mtime_ns):\n                raise ValueError(f\"plan artifact changed while hashing: {path}\")\n        finally:\n            os.close(descriptor)\n    return snapshot\n\n\ndef new_plan_doc(worktree: Path, before: dict[Path, str]) -> Path:\n    \"\"\"Return the sole new or modified plan, rejecting ambiguous evidence.\"\"\"\n\n    after = snapshot_plan_docs(worktree)\n    deleted = sorted(path for path in before if path not in after)\n    if deleted:\n        raise ValueError(\"planning deleted existing plan artifact(s): \" + \", \".join(str(path) for path in deleted))\n    changed = sorted(path for path, digest in after.items() if before.get(path) != digest)\n    if len(changed) != 1:\n        raise ValueError(f\"planning must create or modify exactly one plan artifact; observed {len(changed)}\")\n    return changed[0]\n\n","sourceCodeStart":263,"sourceCodeEnd":299,"githubUrl":"https://github.com/abhigyanpatwari/GitNexus/blob/d540b00184d71a896261ee02670da9a92d59d8f7/eval/workflow_bench/runner_artifacts.py#L263-L299","documentation":"Thrown by snapshot_plan_docs after it has streamed the whole plan file through sha256. Once the digest is computed the descriptor is fstat'd again; if st_size or st_mtime_ns changed between the pre-read fstat and this post-read fstat, the file was modified while being hashed, so the digest does not describe a stable artifact. The harness rejects it to keep evidence reproducible.","triggerScenarios":"(opened.st_size, opened.st_mtime_ns) != (after.st_size, after.st_mtime_ns) after hashlib.file_digest completes. The plan file's size or nanosecond mtime shifted during the read loop.","commonSituations":"An agent still writing its plan when the snapshot runs; an editor/linter auto-formatting the file on a timer; a sync daemon (Dropbox/iCloud) touching the file; a filesystem that updates mtime on read (rare, some FUSE setups).","solutions":["Guarantee the agent process is fully terminated before snapshot_plan_docs runs.","Disable any file-sync or auto-format watcher on the worktree's docs/plans directory.","Move the worktree off any FUSE/network filesystem onto local disk.","Retry the arm once the writing process is confirmed stopped (check no process holds the file open with lsof)."],"exampleFix":"// before\nagent_proc.terminate()  # snapshot may race with flush\nsnapshot = snapshot_plan_docs(worktree)\n\n// after\nagent_proc.wait()  # ensure all writes flushed and fd closed\nsnapshot = snapshot_plan_docs(worktree)","handlingStrategy":"validation","validationCode":"import os, stat, time\nfrom pathlib import Path\n\ndef is_stable_during_read(path: Path) -> bool:\n    st_before = path.stat()\n    with open(path, 'rb') as fh:\n        fh.read()\n    st_after = path.stat()\n    return (st_before.st_size, st_before.st_mtime_ns) == (st_after.st_size, st_after.st_mtime_ns)","typeGuard":"null","tryCatchPattern":"try:\n    snap = snapshot_plan_docs(worktree)\nexcept ValueError as e:\n    if 'changed while hashing' in str(e):\n        # file modified during read; ensure writer is stopped and retry once\n        raise SystemExit('plan modified during hashing; stop the writer and retry')\n    raise","preventionTips":["Confirm no editor, linter, or sync daemon touches docs/plans during the run.","Take the snapshot only after the producing process has exited.","Prefer local disk over FUSE/network filesystems for the worktree.","Log st_mtime_ns before and after if races recur, to identify the writer."],"tags":["toctou","plan-artifacts","integrity","filesystem","workflow-bench"],"backgroundTag":null,"analyzedSha":"d540b00184d71a896261ee02670da9a92d59d8f7","analyzedAt":"2026-08-12T19:50:25.132Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}