Repository navigation
Commit 64dc74c
committed
fix(utils): treat an unanswered drift probe as no evidence of drift
platform-unit-test (ubuntu-latest) was red at 40ac71f on exactly one case:
core/task/__tests__/Task.spec.ts > 'finalizePartialToolAsk still updates the webview when a later
save stage fails' (expected "updateClineMessage" to be called with arguments, Number of calls: 0).
Measured by printing every realpath call in that window with its outcome and, at the same instant,
running fs.statSync on the same path through the never-mocked fs: not one realpath returned ENOENT
for the directory - the guard's first walk and both lock-key canonicalizations resolved it, four
times, to inode 22517998136858990 - and the only ENOENTs were on ui_messages.json itself, the
target file that genuinely is not there. The failure is the mirror image of a missing directory:
the guard's own 'await fs.stat(approvedCanonical)' reported ENOENT for a directory the unmocked fs
reported as that same inode, because Task.spec.ts:144 mocks fs/promises with
'stat: vi.fn().mockRejectedValue({ code: "ENOENT" })' for every path. The second walk never ran, so
the drift comparison never happened. The guard was failing on a question it invented, and the only
reason our own lane never caught it is that the suite which drives this write path mocks stat to
fail for every path.
The guard now acts on a single signal: the re-resolution of the approved directory succeeds and
lands on a different directory. A probe that cannot answer is not an answer - ENOENT because a
component is mid-replacement or because the caller's fs reports the path as missing, EACCES, EPERM,
ELOOP - and a failing probe is a normal condition of this write path rather than an attack, so
_probeApprovedStagingDirectory reports 'no answer' instead of throwing and the write proceeds. The
existence probe is gone. A directory that really was deleted then fails at the staging write
itself: still fail-closed, still with nothing created outside the directory the write was approved
for, and the error names the failed write instead of claiming to be evidence of replacement. The
confinement checks above deliberately keep the opposite rule: there the guard has to prove the write
is inside the scope, and a probe that cannot answer cannot prove anything. The guard's comment
paragraph was duplicated verbatim twice in the previous head (lines 297-301 and 302-306, a chain
artifact); it is collapsed here.
Controls. Reverting this change reddens exactly the two new cannot-answer cases: 'stages anyway when
the guard's probe cannot answer' (EACCES on the re-resolution - the failure the canonicalization
walk itself rethrows, so the guard really is out of answers) and 'stages anyway under the task
persistence suite's blanket missing stat', which puts Task.spec.ts:144's blanket double over a write
whose directory exists and asserts expect(statCalls).toBe(0) (under the old guard that write died
after exactly one stat call). Reverted: 2 failed | 12 passed on the two drift specs, and
Task.spec.ts 1 failed | 198 passed - the CI case and only it. Restored: 5 spec files, 306 passed |
4 skipped, and the restore is byte-identical by SHA256 on all three touched files. Two cases are
labelled in-file as absorbed rather than controls: the re-walk ENOENT case is absorbed by the walk's
retry from its nearest existing ancestor, and the real-deletion case is keyed to the second reading,
so the old guard had already cleared its probe before the directory went. The re-point case is now
decided by the OS rather than by a stub: the approved directory is renamed aside and a junction onto
a different directory is planted at the guard's second reading, realpath left unstubbed and the
wrapper choosing only when the plant happens - the junction being the portable twin of the POSIX
symlink case. safeWriteJson.lockKey.spec.ts's 'refuses to stage when the target's parent no longer
names that directory' used to pass off the deleted existence probe (its stub named a path that never
existed, so the guard rejected for 'no existing directory'); it now flips on the fourth reading with
expect(readings).toBe(4), which is evidence about the re-verification and fails loudly if the guard
ever stops taking it.
Known property, deliberately not fixed here: when the approved directory is replaced before the
guard's first reading, both readings agree and no layer rejects the write - measured byte-for-byte
the same outcome before and after this change (payload published at wherever the approved pathname
now points, no staged file left in either spelling, only the lock directory travelling with the
renamed directory). The file can be published into a directory the writer's lock does not name.
Closing that means safeWriteText binding the publish destination to the directory its own advisory
lock names, which is a design change in the path #1916's guardedWrite layer builds on, so it is a
follow-up unit rather than something to smuggle into a green-lane fix.
Gates, all in split-1833/u7 against its own install: tsc -p tsconfig.json --noEmit exit 0; eslint
--prune-suppressions --max-warnings=0 on the three files exit 0, with src/eslint-suppressions.json
restored to HEAD bytes and the parsed (file,rule)->count delta computed in Node straight from
'git cat-file blob' - headSum 3537, nowSum 3537, 325 keys on both sides, 0 deltas, and the three
touched files carrying no suppression entries at all; prettier --write then --check exit 0; root
'pnpm run check-types' exit 0 (10 packages cache hits, zoo-code:check-types ran 'tsc --noEmit'
fresh). Cross-checked in a scratch merge of 40ac71f with upstream/main
6bbc7a0: the CI case is green there with this fix and red there
without it at the same merge, and the single remaining failure in that tree - main's 'bills a 1h
cache write at the 1h rate', expected +0 to be close to 0.6 - reproduces at pure merge content
without these three files, so it is the scratch tree's donor-linked install resolving
@roo-code/telemetry outside the merge, not this change.1 parent 40ac71f commit 64dc74c
3 files changed
Lines changed: 304 additions & 90 deletions
File tree
- src/utils
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
211 | | - | |
212 | | - | |
213 | | - | |
214 | | - | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
215 | 233 | | |
216 | 234 | | |
217 | 235 | | |
| |||
223 | 241 | | |
224 | 242 | | |
225 | 243 | | |
| 244 | + | |
226 | 245 | | |
227 | 246 | | |
228 | 247 | | |
| |||
0 commit comments