Repository navigation
fix(workspace): restore contents the store lost from disk's matching copy - #811
nissa-seru merged 3 commits into
Conversation
…copy A file the workspace holds is a tree entry naming its contents by their SHA-256, and chronicle keeps those contents as a blob file. When that file was gone, read, edit, edit_many and readBinary answered "Blob not found for:", and grep left the file unsearched, though disk usually holds the same bytes. A blob file chronicle can't read failed the whole grep, the whole materialize that pushed it, and a listing that read a branch intent stored that way. Neither sync nor materialize helped a lost one: disk agrees with the entry by hash, so neither stores anything. contentsOf now reads a version's contents from the store, and where they are missing or unreadable there, reads disk's copy within the mount's boundary and takes it only when its size and SHA-256 are the version's. It stores the copy again, reads the store back to see whether that took, and answers with the bytes. Where disk can't supply them it reads the store once more before reporting them lost. Whoever runs the host gets an ops alert: workspace-contents-restored; workspace-contents-unstored when the store doesn't keep them, as chronicle 0.4.0 keeps a blob file it can't read in place; workspace-contents-lost when no copy with their hash could be read. Each alert names the blob's hash, and the lost and unstored ones say what brings the contents back. - read, readBinary and read_image's read of the workspace's copy restore in place. - edit and edit_many read each file's contents after their pass, restore lost ones, and run the pass again, keeping both passes' changes for the receipt. The plan reads each entry and sets it with nothing awaited between, using the bytes read for that entry's hash. - grep searches readable texts in its loop, restores lost versions (the workspace's and a conflict's recorded disk version) after it, and records every answer in path order, so one unreadable blob no longer fails it. - pushPaths and readIntent read through storedBytes, now in sync.ts: an unreadable workspace copy is skipped, saying so, and an unreadable intent gives its unreadable reason. - Where nothing can restore them, the answer says what the store and disk hold, what didn't happen, and a remedy the mount can take (writeRefusal decides which), each checked by a test that takes it. - read_image serving disk's other image says that it's disk's copy, and where disk's copy can't be shown, its lost answer keeps disk's reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.
Reviewed commit 9c5f4f9 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This bug fix belongs in the workspace module, which already owns workspace storage and syncing with disk. When the store loses a file's contents, or can't read them, the module now restores them from disk's copy if that copy has exactly the same bytes, and it reports the restore or loss through the existing ops-alert hook.
5 reasons with sources
- It fixes existing workspace tools that failed when the store lost a file's contents; it adds no new feature. The README describes WorkspaceModule as a mountable filesystem abstraction with Chronicle tree state, filesystem watching and materialization. Before this change, read, edit, edit_many, grep, readBinary, read_image and materialize answered "Blob not found", or failed the whole call, when a blob was missing or unreadable. The change keeps these tools working on top of what the module already does. (README.md (WorkspaceModule entry))
- Every change stays inside src/modules/workspace, and one shared helper replaces the scattered store reads. The new
storedByteshelper sits in sync.ts next tohashContentand replaces the directstore.getBlobcalls in index.ts, reconcile.ts and branch-intent.ts. Restoring from disk goes through the module's existingopenContainedFile, so it follows the mount's own boundary and symbolic-link rules. (src/modules/workspace/sync.ts, reconcile.ts, branch-intent.ts) - Alerts go through the framework's existing ops-alert hook, so the change adds no new channel. The workspace module already calls ctx.notifyOps (for example 'workspace-mount-not-direct'). The three new alert kinds use the same optional call, so maintainers take on no new interface or dependency. (src/types/module.ts (ModuleContext.notifyOps); src/modules/workspace/index.ts:1718)
- The PR follows the contributing rules: it adds a changelog fragment and tests. It adds changelog.d/workspace-restore-lost-contents.fixed.md, a new test file test/workspace-lost-contents.test.ts, and updates to existing tests whose expected messages changed. The other review's notes on missing test cases and false 'lost' alerts are code defects for that review and don't affect whether the change belongs. (CONTRIBUTING.md (What a PR should contain; Changelog))
- It is one coherent change: every part handles blobs the store has lost or can't read. The changes to materialize's skip reason and to reading branch intents use the same storedBytes helper to handle unreadable blobs. They are part of the same fix, not unrelated work. (CONTRIBUTING.md (How changes land: keep commits coherent))
Findings
3 findings of medium or high severity, see the inline comments:
- Medium severity. grep sends a false 'workspace-contents-lost' alert when a later file with the same hash restores the contents.
- Medium severity. edit_many sends a false 'lost' alert for shared contents that a later target restores.
- Medium severity, test coverage. No test for the edit/edit_many refusal when an entry changed during the restore.
2 more findings, less severe
- Low severity, test coverage,
src/modules/workspace/index.ts:4025-4028. No test where disk's copy has the right size but a different hash. contentsOf takes disk's copy only when its size matches andhashContent(read.bytes) === version.blobHash. In every 'other contents' test the disk text has a different length, so theotherSizeearly throw rejects it and the hash comparison never decides anything. If the hash check were dropped or compared the wrong value, the different bytes would be stored under the old hash and served, and no test would fail. Add a case where disk holds different bytes of the same length. - Low severity, test coverage,
src/modules/workspace/reconcile.ts:1390-1404. Forced push of a stale copy whose blob is unreadable has no test. pushPaths now reads through storedBytes in two places. The new materialize test only reaches the ordinary write branch (around line 1555), using a draft whose blob is damaged. The forced 'stale copy' branch (v.adopt with opts.force) also changed: it used to throw on an unreadable blob and now skips the path with unstoredCopy's reason. No test covers that branch, so a regression there, such as going back to plain getBlob, would not be caught.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| } | ||
| const restored = new Map<Lost, { bytes: Buffer } | { lost: LostContents }>(); | ||
| for (const item of found) { | ||
| if (!('lost' in item)) continue; | ||
| const { file, relativePath, hash, size, what } = item.lost; | ||
| restored.set(item.lost, await this.contentsOf(mount, relativePath, file, { blobHash: hash, size }, what)); |
There was a problem hiding this comment.
Medium severity. grep sends a false 'workspace-contents-lost' alert when a later file with the same hash restores the contents.
The restore loop calls contentsOf for each lost item in path order, and contentsOf sends workspace-contents-lost as soon as one path's disk copy doesn't match. If a later path holds the same blob hash and its disk copy matches, that path restores the contents and workspace-contents-restored follows. The operator has already been told the contents are gone and to put the blob file back from a backup copy of the store, which isn't true. The new test 'grep searches contents two files share once either restores them' asserts this lost-then-restored sequence. Because the webhook posts at most once per kind every 15 minutes, the false 'lost' post also takes that kind's slot. Run every restore attempt for a hash before deciding whether to alert that it is lost, or group the lost items by hash first.
| if (toRecover.length > 0) { | ||
| for (const { target, entry } of toRecover) { | ||
| if (contents.has(entry.blobHash)) continue; | ||
| const found = await this.contentsOf(mount, target.relativePath, target.path, entry); | ||
| if ('bytes' in found) contents.set(entry.blobHash, found.bytes); | ||
| else lost.set(target.relativePath, { blobHash: entry.blobHash, lost: found.lost }); | ||
| } |
There was a problem hiding this comment.
Medium severity. edit_many sends a false 'lost' alert for shared contents that a later target restores.
The toRecover loop calls contentsOf once for each target, in order. When target A's disk copy differs and target B shares A's blob hash and has a matching disk copy, contentsOf for A sends workspace-contents-lost, then B restores the contents and they go into contents. A is then planned from those bytes and the edit succeeds. So the call works, but the host operator has been told the contents are unrecoverable and that the blob must be restored from a backup. Try restoring each hash from every target that holds it before sending a loss alert.
| text: found?.blobHash === entry.blobHash | ||
| ? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}` | ||
| : `The workspace's copy of ${target.path} changed while ${subject} restored lost contents from disk, and is ${lostInStore({ store: stored })} too, so ${subject} changed nothing. Make the same call again.`, |
There was a problem hiding this comment.
Medium severity, test coverage. No test for the edit/edit_many refusal when an entry changed during the restore.
The new else branch handles an entry whose blob hash changed between the restore and the second pass and is again not in the store. It answers "…changed while … restored lost contents from disk… Make the same call again." No test reaches it: every new edit test has found.blobHash === entry.blobHash. The template text also passes lostInStore({ store: stored }) where stored is a narrowed StoredBytes. A test that changes the entry during the await (for example, a concurrent write or a disk change the second pass takes in, onto an unstored blob) should cover it.
…y only where sync proves absence - edit, edit_many and grep restore lost versions by hash (restoreEach): each hash from the first of its files whose disk copy has it, every file holding it taking those bytes. workspace-contents-lost goes out, once per file, only when none of them restored it, so a file whose own disk copy differs no longer reports a loss another file then restores, and a hash the store won't keep is read and alerted once. - grep searches each restored version as soon as its bytes are in hand and keeps only the answer, rather than holding every restored file's bytes until all restores end. - A disk read that finds nothing counts as no copy only where confirmAbsent, the proof sync uses before taking a deletion, holds. A path beneath a link that leads nowhere is a copy disk can't read, with the proof's reason, so its remedy is no longer a sync that sync can't take. - The read-only remedy no longer says only a change on disk can end the loss (sync with acceptRoot, or putting back the blob file, also can), and no longer says a read restores what a damaged blob file blocks: it says a read reads it from disk's copy if that copy has the same contents. - Each alert's message opens with the blob's hash, so the webhook's 500-character bound can't cut it; a loss reports the store's state as last read; read_image gives disk's reason once; BranchIntent.unreadable's doc no longer says a put of the same contents replaces a damaged record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he unstored alert from the store after the write - diskLacks is part of a read and records nothing, so it builds its view with rootIdentityForRead's identity, as readRoot does, rather than runtime()'s rootIdentity, which throws while the disk-agreement evidence awaits reopening and so failed a read that had passed its root check, losing its workspace-contents-lost alert. A root the read couldn't reach (mount_unavailable) is a copy disk can't read, in the read's own words, without asking for a proof of absence. - workspace-contents-unstored speaks of the store as it is after the write, so a write that leaves a blob file chronicle can't read is told as unreadable, with its storeError, rather than as missing. Its reason, where the write threw nothing, no longer says the store "still" lacks them, which assumed the state before the write was the same. - A test: read_image of a lost image with no copy on disk, while the disk-agreement evidence awaits reopening after a failed append, answers the loss and tells workspace-contents-lost. - A test: two files share contents whose blob the store can't read, one with other contents on disk and one with the matching copy. grep and edit_many answer both from that copy, in both path orders, telling one workspace-contents-unstored and no loss; and where both hold the matching copy, it's read from disk and told once, not once per file. For grep, the loss alert is held for the files one mount's grep reads, each mount in its own turn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, src/modules/workspace/index.ts's import from ./observe.js: anima-research#624's list, plus CHANGED and confirmAbsent, which F9 (anima-research#811) added to it and uses at main's confirmAbsent call. Nothing else conflicted.
A workspace file whose stored contents the store has lost, or can't read, is now read from disk's copy when that copy has exactly those bytes. The contents are stored again, and whoever runs the host is told. Where nothing can restore them, the answer says so and what the agent can do instead.
Problem
Each file a
chroniclemount holds is a tree entry that names its contents by their SHA-256. Chronicle keeps the contents as a blob file under that hash. Measured onmain(0a4a2c69) with the blob's file deleted and the store opened again, while disk still held exactly the same bytes:read,editandreadBinary, which the framework's ownread_imageroute calls, answered "Blob not found for: work/a.txt".edit_manyanswered "…stored none of its edits: 1 problem. edit 1: Blob not found for: work/a.txt".grepfound nothing in the file and listed it as "the workspace copy is missing from the store, so it was not searched".syncsynced nothing, andmaterialize, plain or forced, materialized nothing. Disk's copy agrees with the entry by hash, so neither stores anything, and a read after the sync still failed.lsshowed the filesynced.read_imageanswered from disk's file. With a different image on disk, it served that one with nothing saying it wasn't the workspace's copy.A blob file chronicle can't read whole, or whose format, checksum or hash doesn't check out, makes
getBlobthrow (getin chronicle'ssrc/blobs/storage.rs). By reading, chronicle 0.4.0'sstoreBlobwrites to the blob's final path and takes any file already there as stored, so a write cut short leaves such a file. With one cut short by 6 bytes,readanswered "IO error: failed to fill whole buffer".grepover the mount failed the whole call, though another file matched. Writing the same text again changed nothing. By reading, the same throw fails a wholematerializethat pushes the file, and a listing that reads a branch intent whose blob is cut short.By reading, nothing in chronicle 0.4.0 or agent-framework deletes a blob file: chronicle's
BlobStorage::deletehas no caller and no binding. A blob file goes missing when something outside them removes or loses it. A blob deleted while the process runs stays readable from chronicle's cache of recently used blobs (1,000 unless the store is opened with another size), so the loss shows after a restart or an eviction.Changes
One restore (
contentsOf). A read of a version's contents reads the store first (storedBytes, which tells chronicle's three answers apart). Where the store has no file for them, or one chronicle can't read, it reads disk's copy within the mount's boundary:openContainedFile, with the mount's own rule for symbolic links. It takes the copy only when its size and SHA-256 are the version's, stores it again withstoreBlob, reads the store again to see whether that took, and answers with the bytes. Where disk can't supply them, it reads the store once more before reporting them lost, since another read may have restored them meanwhile. Disk has no copy only where sync would take its deletion: a disk read that finds nothing (ENOENT or ENOTDIR) asksconfirmAbsent, the proof of absence sync uses, and anything it doesn't prove, such as a path beneath a link that leads nowhere, is a copy disk can't read, with that proof's reason. A restore or a loss sends an ops alert through the module context'snotifyOps, with agentworkspace, a message that opens with the blob's hash, and datamount,pathandblobHash, plusstoreErrorwhere chronicle couldn't read the blob:workspace-contents-restored: stored again.workspace-contents-unstored: answered from disk's copy, but the store still doesn't give the contents, withunstoredsaying why. Chronicle keeps a blob file it can't read in place, so where the store can't read the file, the alert says that if the file is damaged, removing it lets the next read store them.workspace-contents-lost: no copy with their hash could be read, saying what disk holds and that putting back the store's blob file for that hash, from a copy of the store, brings them back.failures.logand theops:alerttrace get every alert, so each read that answers from disk because the store won't keep the contents, or finds them lost, adds one. The webhook posts at most once per kind every 15 minutes, as for every framework alert.read,readBinaryandread_image's read of the workspace's copy restore in place. The await comes after the entry is read, and the answer is that entry's bytes.Several files at once (
restoreEach, foredit,edit_manyandgrep, which restores each mount's files in that mount's turn). Lost versions are restored by hash: each hash from the first of its files whose disk copy has it, and every file holding it then takes those bytes. Whoever runs the host is told a hash is lost, once for each of its files, only when none of the files the call reads in that mount restored it and the store still lacks it. So a file whose own disk copy differs raises noworkspace-contents-lostwhen another file restores the contents, and a hash the store won't keep is read and alerted once.editandedit_manyread each named file's stored contents after their pass. Where the store has lost some, they restore them, then run the pass again, and the plan reads that second pass and both passes' changes taken in from disk. Each file's entry is read in the plan and set with nothing awaited between, as before, and the bytes the plan uses are that entry's own, by hash: those read before planning, restored or stored, so the edits plan from disk's copy even where the store couldn't keep it. The plan reads the store again only for a version it has no bytes for. A file whose contents turn out lost after the second pass, because its entry changed meanwhile, is refused, and the call can be made again.grepsearches each readable text inside its synchronous loop and keeps only the answer. A lost version is held in its place: the workspace's version, or a conflict's recorded disk version. After the loop the lost versions are restored by hash, and each is searched as soon as its bytes are in hand, keeping only the answer. The answers are then recorded in path order, results, skips and the CRLF and BOM notes alike, so contents two files share are searched in both, whichever file restored them. Each restored version has the version's own hash, so it's exactly what the pass labelled. One blob chronicle can't read no longer fails the whole call.materialize's push (pushPaths) reads the workspace's copy throughstoredBytes. A copy chronicle can't read is listed as skipped, "the workspace copy is unreadable in the store (…)", and the other paths go ahead. A missing one is skipped as onmain. A push restores nothing from disk.readIntentreads a branch intent throughstoredBytes, so an intent whose blob chronicle can't read gives its "unreadable" reason, as a missing one does, and the listing answers.Where nothing can restore them, the answer says what the store and disk hold, what didn't happen, and what the agent can do. Each state's words below were checked by a test that takes the remedy and reads the file after:
readanswerschronicleThe last two are for a mount that takes no writes or deletions (
writeRefusal), so they name a sync.editsays "…so it can't be restored, and the edit changed nothing." before the remedy, andedit_manynames the edits.greplists the version as not searched, saying what the store and disk hold.readBinaryanswers asreaddoes, and so doesread_imagewhere disk has no copy. Where disk's copy holds other contents that can't be shown,read_imagesays why, in disk's own words. A copy behind a link that leads nowhere, at the path or on its way, is one disk can't read, not one disk lacks, since sync can't take a deletion there. Where the store can't read the blob, "write the file again" is left out, since writing the same bytes leaves chronicle's file as it is.read_imageserving disk's copy of a file whose workspace copy is lost adds a line to its text: "The workspace's copy of work/p.png is missing from the store, so this is disk's copy, which holds other contents."What each part is for
storedBytes, insync.tsso thatreconcile.tsandbranch-intent.tscan use it, tells chronicle's three answers apart: the bytes,null(no file), and a throw (a file it can't read whole, or whose format, checksum or hash doesn't check out). Before, a throw failed the whole call. A closed store's throw is passed on, since it isn't about the blob.LostContentsand its text helpers (lostText,lostInStore,lostOnDisk,nothingToRead,lostRemedy) put each lost answer's words in one place for the five sites that give them. Itswritablepicks a remedy the mount can take.contentsOf:diskLacksasksconfirmAbsentbefore calling disk's copy absent, since the remedy for "no copy" is sync's deletion, which sync takes only where that proof holds. It asks in the view a disk read checks its root against (rootIdentityForRead's identity), since it's part of a read and records nothing, and a root the read couldn't reach is a copy disk can't read, in the read's own words.CHANGEDis exported fromobserve.tsso that a link at the path itself, which the proof finds present, keeps disk's own error as its reason;storeBlobis how it knows whether the contents took, sincestoreBlobreports success for a file it kept and can throw after writing one;storeBlobkeeps a second restore of the same contents, such asread_image's outside the mount's turn, from storing and alerting twice;whatnames a conflict's recorded disk version in the alert, whichgreprestores too;telllets a caller restoring several files hold the loss alert until every file holding the hash has been tried.tellLostsends it, andcontentsSubjectandinTheStoreword all three alerts.restoreEachrestores lost versions by hash foredit,edit_manyandgrep, so the loss alert is true of every file the call reads in a mount, and a hash is read from disk and stored once, and told restored or unstored once, however many files hold it.readImageFromTreeistryReadImageFromTreemade async, since it restores, and returning the lost contents where it threwblob_missing. That code is removed fromWorkspaceImageReadError: nothing throws it now.edit's reads before planning: thecontentsmap holds each version's bytes, so the plan reads none twice. The second pass is what the plan then reads, so a restore that let disk or the branch move can't leave the plan reading a stale pass.opskeeps the first pass's changes for the receipt's notes. The lost map is keyed by path, since two files can share lost contents and hold different things on disk.grep's search, split from its recording, keeps memory to each file's matches, restored files' included, while the recordings wait for the lost versions in path order.unstoredCopywordspushPaths' two skips.imageResult'snotecarriesread_image's line about disk's copy.read_image's lost answer for any refusal of disk's copy tells the agent the workspace's copy is lost, which disk's refusal alone didn't, and gives disk's reason once.edit's which-copy and conflict notes go in only once a file's bytes are read: a lost file's conflict note offered to force a copy that can't be read onto disk.grep'sanswersmap holds each lost version's answer, its matches or a skip, until the recording reaches it in path order.BranchIntent.unreadable's doc says a put replaces a damaged record, except where it puts the same contents over a blob file chronicle can't read, which chronicle keeps.Envopens a module on a real store, and itsrestartAfter,loseanddamagestop the module, change blob files as a restart would find them, and open both again.syncedanddraftbuild the two states a file is lost from. Inworkspace-three-way.test.ts,conflictWithBothVersionsLostbuilds a conflict whose two stored versions are both lost.Tests
Requirements, each with the test that pins it, in
test/workspace-lost-contents.test.tsunless named. Each test in that file loses or damages a blob for real: it stops the module, closes the store, deletes or truncates the blob's file, and opens both again.readanswers with them, the store holds them again, oneworkspace-contents-restoredalert names the mount, path and hash, and a second read raises none ("read answers with them…").editandedit_manymake their edits to them, andmaterializewrites the edited text ("edit and edit_many make their edits…").grepsearches them in path order among other files ("grep searches them…"), and searches contents two files share once either restores them, in both path orders, with noworkspace-contents-lost("grep searches contents two files share…").edit_manyedits two such files, with no loss alert either ("edit_many edits files that share contents…").read_imageandreadBinaryanswer with them ("read_image and readBinary…").edit_manythat restores one file still says what it took in from disk for another ("edit_many that restores one file…").edit's refusal; "disk has no copy…"; "edit_many refuses…"; "edit_many tells each file's own state…" and "grep tells each file's own state…", with one alert for each file naming its own disk state; "grep lists it as not searched…"; "on a mount configured direct…").read_imageserves disk's other image with its line, says the copy is lost with no file on disk ("read_image serves disk's other image…"), and where disk's file isn't an image, with disk's reason ("read_image says the workspace's copy is lost…"). Where disk's copy can't be read, it gives disk's reason once ("read_image gives disk's reason once…"), and while the disk-agreement evidence awaits reopening after a failed append, it still tells the loss, since it checks the root as the read does ("read_image tells a loss while…"). The lost alert's advice works: putting back the blob file brings the contents back ("whoever runs the host is told that putting back…").workspace-contents-unstored, andgrepfinds both files' matches ("a read is answered from disk's matching copy…").editandedit_manyplan from disk's copy and store their change ("edit and edit_many plan from disk's matching copy…").grepandedit_many, in both path orders, with oneworkspace-contents-unstoredand no loss ("files sharing contents whose blob the store can't read are all answered…"). Where both hold the matching copy, it's read from disk and told once ("…each with the matching copy on disk…").materializeskips that path, saying why, and writes the rest ("materialize lists a draft whose blob it can't read…"). A forcedmaterializeover disk's newer copy skips it too, leaving disk's copy ("a forced materialize over disk's newer copy…").grep, a conflict's versions (test/workspace-three-way.test.ts): a conflict's recorded disk version, lost while disk still holds it, is searched, and with disk's file gone, both versions are listed as not searched with what the store and disk hold. These replace one test that pinned the old skip.edit_many(test/workspace-edits.test.ts): the test of a file whose stored copy is missing, which stands in forgetBlob, now gives that file a draft disk doesn't hold, so nothing can restore it, and pins the new words.Runs, all on Linux:
npm testat this head, Node 24.22.1: 7,613 tests, 7,569 pass, 0 fail, 44 skipped. CI's run ofmain(0a4a2c69) on ubuntu and Node 24 counted 7,572 tests, 7,527 pass and 45 skipped. The 41 more tests are this change's 40 new ones and thegreptest split in two. The one fewer skip is "under Bun, a failed dial's error leaves no credential…", which CI's job skips because Bun isn't on itsPATH.main's code with this head's tests, all 40 new tests fail, each atmain's answer: "Blob not found for:", the old skip, chronicle's error failing the call, no alert, or no line about disk's copy. The three changed tests fail there too, atmain's answers.Other open pull requests
Of the 46 open pull requests, one conflicts with this head where it merges cleanly with
main: #624, in one line,index.ts's import fromobserve.js, to which both add names. #271 already conflicts withmaininsrc/modules/workspace/index.tsandsrc/modules/workspace/sync.ts, and this change altersmain's side of four of those conflicts:index.ts's imports fromsync.jsandobserve.js,handleReadImage's read of the workspace's copy,handleGrep'ssearch, and the end ofsync.ts, where both add code.Not verified
get; the tests only cut one short.notifyOps, and its posting and cooldown are the framework's, unchanged.Out of scope
syncandmaterializeof a file whose disk copy agrees with its lost contents by hash do nothing, as onmain: there's nothing to take in or push. A read then restores it.materializeof a file whose workspace copy is lost and disk holds other contents: disk's copy isn't the contents, so there's nothing to push, and it's listed as skipped as onmain.lsandstatus, which don't read contents: once reads restore them, a file's state is true of disk and agreement as before.storeBlob, which keeps a file it can't read in place: that is chronicle's code.Who did what
I built the change. Ada-1017 (Claude Opus 5.5) made the design calls: a restore checked by hash on every read of stored contents, edit's restore before planning, grep's after its loop, the words where nothing can restore them, a blob chronicle can't read treated as a lost one, a loss told only when no file holding the contents restores them, and "no copy" judged by sync's own proof of absence. Naomi-2422 (Claude Opus 5.5), who built none of it, read each head and approved this one. Three internal reviews read it, the last at the head it opened with, and this head answers their findings and those of the automated review at opening.
changelog.d/workspace-restore-lost-contents.fixed.mdWritten by Pippa-2571 (Claude Opus 5.5).
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.