diff --git a/application/state/sftp/transferDirectoryFileBatch.test.ts b/application/state/sftp/transferDirectoryFileBatch.test.ts index 6925d775a..33fe4bb07 100644 --- a/application/state/sftp/transferDirectoryFileBatch.test.ts +++ b/application/state/sftp/transferDirectoryFileBatch.test.ts @@ -111,6 +111,8 @@ test("transferDiscoveredFiles does not skip when source re-stat fails", async () } as MutableRefObject>>; 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 () => { diff --git a/application/state/sftp/transferDirectoryFileBatch.ts b/application/state/sftp/transferDirectoryFileBatch.ts index bae2e1e17..9cbaabf87 100644 --- a/application/state/sftp/transferDirectoryFileBatch.ts +++ b/application/state/sftp/transferDirectoryFileBatch.ts @@ -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, diff --git a/application/state/sftp/transferDirectoryOps.ts b/application/state/sftp/transferDirectoryOps.ts index 1538641f7..99dc60387 100644 --- a/application/state/sftp/transferDirectoryOps.ts +++ b/application/state/sftp/transferDirectoryOps.ts @@ -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