fix(sftp): omit stale listing size after re-stat failure

When source re-stat fails, clear child totalBytes/mtime so
startStreamTransfer omits an explicit snapshot and the bridge
re-stats on the dedicated session instead of truncating to the
stale listing size.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
This commit is contained in:
Cursor Agentand陈大猫 committed 2026-08-06 10:13:45 +00:00
1 parent f671f58b33
commit 85751a9fc5
3 files changed
+26 -3

No files matched your search

@@ -111,6 +111,8 @@ test("transferDiscoveredFiles does not skip when source re-stat fails", async ()
} as MutableRefObject<Map<string, Set<string>>>;
let transfers = transfersRef.current;
let transferCalls = 0;
let transferredTotalBytes: number | undefined;
let transferredSourceLastModified: number | undefined;
const mtime = 1_700_000_000_000;
await transferDiscoveredFiles({
@@ -140,8 +142,10 @@ test("transferDiscoveredFiles does not skip when source re-stat fails", async ()
},
waitWhileTransferPaused: async () => {},
isPauseLatched: () => false,
transferFile: async () => {
transferFile: async (task) => {
transferCalls += 1;
transferredTotalBytes = task.totalBytes;
transferredSourceLastModified = task.sourceLastModified;
},
tryStatTarget: async () => ({
size: 10,
@@ -152,6 +156,12 @@ test("transferDiscoveredFiles does not skip when source re-stat fails", async ()
});
assert.equal(transferCalls, 1, "missing fresh source meta must not skip");
assert.equal(transferredTotalBytes, 0, "must not pass stale listing size after re-stat failure");
assert.equal(
transferredSourceLastModified,
undefined,
"must clear stale listing mtime after re-stat failure",
);
});
test("transferDiscoveredFiles skips only when fresh source still matches", async () => {
@@ -147,6 +147,12 @@ export async function transferDiscoveredFiles(
lastModified: skipSourceLastModified,
});
freshSourceOk = true;
} else {
// Do not pass stale pre-scan size as an explicit snapshot (Codex P2).
// Child totalBytes 0 is omitted at startStreamTransfer via `|| undefined`,
// so the dedicated transfer session re-stats the source itself.
skipSourceSize = 0;
skipSourceLastModified = 0;
}
}
if (skipUnchanged && !file.isSymlink && freshSourceOk) {
@@ -224,7 +230,7 @@ export async function transferDiscoveredFiles(
progressMode: "bytes",
parentTaskId: rootTaskId,
totalBytes: skipSourceSize,
sourceLastModified: skipSourceLastModified,
sourceLastModified: skipSourceLastModified || undefined,
directoryEntryIndex,
directoryEntryIdentity,
retryable: rootTask.retryable,
@@ -895,6 +895,13 @@ export function useSftpDirectoryTransferOps({
lastModified: transferLastModified,
});
freshSourceOk = true;
} else {
// Do not pass stale listing size as an explicit snapshot: the
// dedicated transfer session may still read a grown file (Codex P2).
// Child totalBytes 0 is omitted at startStreamTransfer via
// `|| undefined`, so the bridge re-stats instead of truncating.
transferSize = 0;
transferLastModified = 0;
}
}
@@ -984,7 +991,7 @@ export function useSftpDirectoryTransferOps({
progressMode: "bytes",
parentTaskId: rootTaskId,
totalBytes: transferSize,
sourceLastModified: transferLastModified,
sourceLastModified: transferLastModified || undefined,
directoryEntryIndex,
directoryEntryIdentity,
// Inherit retryable from parent - downloadToLocal sets retryable: false