comfyui-mcp 0.52.113 → 0.52.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/orchestrator/download-done-guard.js +83 -16
- package/dist/orchestrator/download-done-guard.js.map +1 -1
- package/dist/orchestrator/download-done-loop.js +38 -0
- package/dist/orchestrator/download-done-loop.js.map +1 -0
- package/dist/orchestrator/index.js +52 -7
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +9 -3
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +85 -40
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/download-jobs.js +180 -49
- package/dist/services/download-jobs.js.map +1 -1
- package/dist/services/download-progress.js +29 -15
- package/dist/services/download-progress.js.map +1 -1
- package/dist/tools/model-management.js +8 -5
- package/dist/tools/model-management.js.map +1 -1
- package/package.json +1 -1
|
@@ -18,7 +18,7 @@ import { realpath } from "node:fs/promises";
|
|
|
18
18
|
import { extname, basename, dirname, join } from "node:path";
|
|
19
19
|
import { downloadModel, findResumablePartialForLocalDownload, liveListingHasEntry, managerJobFilename, resolveDownloadTarget, shouldDispatchDownloadToManager, verifyLandedModel, verifyManagerVisibility, } from "./model-resolver.js";
|
|
20
20
|
import { ModelError } from "../utils/errors.js";
|
|
21
|
-
import { readDownloadProgress, listInFlightDownloadProgress, persistDownloadJob,
|
|
21
|
+
import { readDownloadProgress, listInFlightDownloadProgress, persistDownloadJob, currentDownloadTarget, listPersistedDownloadJobs, findPersistedDownloadJob, removePersistedDownloadJobFor, clearDownloadProgress, persistedRecordsEnabled, PERSIST_OWNER, PERSISTED_INFLIGHT_STALE_MS, } from "./download-progress.js";
|
|
22
22
|
import { logger } from "../utils/logger.js";
|
|
23
23
|
/** How often an in-flight job re-persists its record (liveness heartbeat). Must stay
|
|
24
24
|
* comfortably below PERSISTED_INFLIGHT_STALE_MS so a live download is always fresh. */
|
|
@@ -311,6 +311,7 @@ dispatchToManagerOverride) {
|
|
|
311
311
|
// when locally resolvable (two-URLs-one-dest dedup).
|
|
312
312
|
const lookupKeys = destKey ? [reqKey, destKey] : [reqKey];
|
|
313
313
|
const trayId = downloadIdFor(url);
|
|
314
|
+
const downloadTarget = currentDownloadTarget();
|
|
314
315
|
// ADOPT ONLY AN IN-FLIGHT ENTRY (#420 codex round 3, rule 3): a FINISHED entry
|
|
315
316
|
// under any key is treated as absent — it must never shadow a currently-downloading
|
|
316
317
|
// writer reachable under another key, nor cause a retire that overwrites a live row.
|
|
@@ -375,6 +376,7 @@ dispatchToManagerOverride) {
|
|
|
375
376
|
rec.status === "downloading" &&
|
|
376
377
|
nowForAdopt - (rec.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS &&
|
|
377
378
|
rec.trayId === trayId &&
|
|
379
|
+
rec.target === downloadTarget &&
|
|
378
380
|
(rec.id === id || (rec.req_key !== undefined && rec.req_key === reqKey)) &&
|
|
379
381
|
// A FRESH HEARTBEAT IS NOT A LIVE WRITER. The heartbeat is written every
|
|
380
382
|
// 15s and only goes stale after 60, so a process that died a moment ago
|
|
@@ -417,6 +419,7 @@ dispatchToManagerOverride) {
|
|
|
417
419
|
const job = {
|
|
418
420
|
id,
|
|
419
421
|
trayId,
|
|
422
|
+
target: downloadTarget,
|
|
420
423
|
url,
|
|
421
424
|
target_subfolder: targetSubfolder,
|
|
422
425
|
filename,
|
|
@@ -773,11 +776,12 @@ function finalizeCancelled(job) {
|
|
|
773
776
|
* URL. No-op outside the panel. Keeps the persisted copy in step with the job.
|
|
774
777
|
* Returns whether the record was DURABLY replaced (so the heartbeat can retry a
|
|
775
778
|
* transiently-failing terminal persist). */
|
|
776
|
-
function persistJobRecord(job) {
|
|
779
|
+
function persistJobRecord(job, owner = PERSIST_OWNER) {
|
|
777
780
|
return persistDownloadJob({
|
|
778
781
|
id: job.id,
|
|
779
782
|
trayId: job.trayId,
|
|
780
783
|
progressId: job.progressId,
|
|
784
|
+
target: job.target,
|
|
781
785
|
url: job.url,
|
|
782
786
|
target_subfolder: job.target_subfolder,
|
|
783
787
|
filename: job.filename,
|
|
@@ -797,7 +801,34 @@ function persistJobRecord(job) {
|
|
|
797
801
|
disk_verified: job.disk_verified,
|
|
798
802
|
verified_root: job.verified_root,
|
|
799
803
|
reclaimed_dead: job.reclaimedDead,
|
|
800
|
-
});
|
|
804
|
+
}, owner);
|
|
805
|
+
}
|
|
806
|
+
function boundedDownloadJobError(value) {
|
|
807
|
+
if (typeof value !== "string")
|
|
808
|
+
return undefined;
|
|
809
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
810
|
+
return compact ? compact.slice(0, 400) : undefined;
|
|
811
|
+
}
|
|
812
|
+
/** Turn a stale/frozen in-flight record into the terminal state the tray is reporting. */
|
|
813
|
+
export function reconcileStalledDownloadRecord(job, error) {
|
|
814
|
+
if (job.status !== "downloading")
|
|
815
|
+
return true;
|
|
816
|
+
const wasPersisted = job.updated !== undefined;
|
|
817
|
+
job.status = "error";
|
|
818
|
+
job.error = boundedDownloadJobError(error);
|
|
819
|
+
job.finished_at = Date.now();
|
|
820
|
+
job.updated = job.finished_at;
|
|
821
|
+
// A reconstructed foreign record must be terminalized in its ORIGINAL owner file;
|
|
822
|
+
// writing a fresh current-owner snapshot leaves the original fresh downloading row
|
|
823
|
+
// authoritative to status. Legacy persisted records without an owner cannot be safely
|
|
824
|
+
// reconciled, so callers keep the event explicitly disclosed instead.
|
|
825
|
+
if (wasPersisted && !job.owner)
|
|
826
|
+
return false;
|
|
827
|
+
const durable = persistJobRecord(job, job.owner ?? PERSIST_OWNER);
|
|
828
|
+
// In non-panel mode the in-memory object is the authoritative status store. In panel
|
|
829
|
+
// mode a failed persist leaves the old downloading snapshot visible, so the caller must
|
|
830
|
+
// keep the event explicitly reconciled instead of emitting an unflagged FAILED row.
|
|
831
|
+
return durable || !persistedRecordsEnabled();
|
|
801
832
|
}
|
|
802
833
|
/** Rebuild an in-memory DownloadJob view from a persisted record (#529 adoption
|
|
803
834
|
* after a reconnect). It is a read-only snapshot — there is no live AbortController
|
|
@@ -808,10 +839,13 @@ function jobFromPersisted(rec) {
|
|
|
808
839
|
id: rec.id,
|
|
809
840
|
trayId: rec.trayId,
|
|
810
841
|
progressId: rec.progressId,
|
|
842
|
+
target: rec.target,
|
|
843
|
+
owner: rec.owner,
|
|
811
844
|
url: rec.url,
|
|
812
845
|
target_subfolder: rec.target_subfolder,
|
|
813
846
|
filename: rec.filename,
|
|
814
847
|
status: rec.status,
|
|
848
|
+
updated: rec.updated,
|
|
815
849
|
path: rec.path,
|
|
816
850
|
error: rec.error,
|
|
817
851
|
started_at: rec.started_at,
|
|
@@ -842,10 +876,10 @@ function jobFromPersisted(rec) {
|
|
|
842
876
|
* distinct URLs resolving to the same dest+auth). The id alone can't disambiguate, so
|
|
843
877
|
* a by-id lookup/cancel must decline rather than silently act on the local one.
|
|
844
878
|
* Excludes this process's own records (same owner) and heartbeat-stale ones. */
|
|
845
|
-
function hasAmbiguousForeignSibling(id, localTrayId) {
|
|
879
|
+
function hasAmbiguousForeignSibling(id, localTrayId, localTarget) {
|
|
846
880
|
const now = Date.now();
|
|
847
881
|
return listPersistedDownloadJobs().some((rec) => rec.id === id &&
|
|
848
|
-
rec.trayId !== localTrayId &&
|
|
882
|
+
(rec.trayId !== localTrayId || rec.target !== localTarget) &&
|
|
849
883
|
rec.owner !== PERSIST_OWNER &&
|
|
850
884
|
rec.status === "downloading" &&
|
|
851
885
|
now - (rec.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
|
|
@@ -1015,7 +1049,7 @@ ctx) {
|
|
|
1015
1049
|
/**
|
|
1016
1050
|
* Every DISTINCT tracked download that answers to `id`, across this process's
|
|
1017
1051
|
* registry AND the persisted store, deduped by the TRUE composite identity
|
|
1018
|
-
* (id, trayId) — the same key `listDownloadJobs
|
|
1052
|
+
* (id, trayId, target) — the same target-aware key `listDownloadJobs` uses.
|
|
1019
1053
|
*
|
|
1020
1054
|
* `id` is derived from destination+auth, so two different SOURCE URLs landing on
|
|
1021
1055
|
* one file deliberately share it (#467 P1-A dedup). That makes `id` a
|
|
@@ -1027,7 +1061,7 @@ ctx) {
|
|
|
1027
1061
|
* Newest-started first, so a caller printing candidates leads with the live one.
|
|
1028
1062
|
*/
|
|
1029
1063
|
export function listDownloadJobCandidates(id) {
|
|
1030
|
-
const keyOf = (j) => `${j.id}\n${j.trayId}`;
|
|
1064
|
+
const keyOf = (j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`;
|
|
1031
1065
|
const byKey = new Map();
|
|
1032
1066
|
for (const e of new Set(jobs.values())) {
|
|
1033
1067
|
if (e.job.id !== id)
|
|
@@ -1038,7 +1072,8 @@ export function listDownloadJobCandidates(id) {
|
|
|
1038
1072
|
}
|
|
1039
1073
|
// `trayId` is a hash of the URL, so it does NOT separate sessions: this
|
|
1040
1074
|
// session's SETTLED record for a URL and ANOTHER session's still-running
|
|
1041
|
-
// download of the same URL to the same destination collapse to one
|
|
1075
|
+
// download of the same URL to the same destination collapse to one target-aware
|
|
1076
|
+
// key. Which
|
|
1042
1077
|
// one is reported matters — showing the local "cancelled" row while a foreign
|
|
1043
1078
|
// transfer is live would hide a running download behind a stale verdict, and
|
|
1044
1079
|
// that is worse than the ambiguity this function exists to surface.
|
|
@@ -1083,23 +1118,33 @@ export function listDownloadJobCandidates(id) {
|
|
|
1083
1118
|
* Resolve ONE download.
|
|
1084
1119
|
*
|
|
1085
1120
|
* `trayId` is the DISAMBIGUATOR (#822): with it the lookup keys on the full
|
|
1086
|
-
* composite identity (id, trayId) and can
|
|
1121
|
+
* composite identity (id, trayId, target) and can name exactly one row when the
|
|
1122
|
+
* current target is known. Without
|
|
1087
1123
|
* it, an id that denotes more than one download still resolves to nothing —
|
|
1088
1124
|
* but callers can now distinguish that from "no such download" by asking
|
|
1089
1125
|
* {@link listDownloadJobCandidates}, which is the difference between an
|
|
1090
1126
|
* actionable "say which one" and a false "it never existed".
|
|
1091
1127
|
*/
|
|
1092
|
-
export function getDownloadJob(id, trayId) {
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1128
|
+
export function getDownloadJob(id, trayId, target) {
|
|
1129
|
+
const targetSelector = target ?? currentDownloadTarget();
|
|
1130
|
+
const candidates = listDownloadJobCandidates(id).filter((j) => (!trayId || j.trayId === trayId) &&
|
|
1131
|
+
(targetSelector === undefined || j.target === targetSelector));
|
|
1132
|
+
const selectOne = (rows) => {
|
|
1133
|
+
const identities = new Set(rows.map((j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`));
|
|
1134
|
+
return identities.size === 1 ? rows[0] : undefined;
|
|
1135
|
+
};
|
|
1136
|
+
if (trayId)
|
|
1137
|
+
return selectOne(candidates);
|
|
1096
1138
|
// The registry indexes each job under its request key and (when local) its
|
|
1097
1139
|
// destination key; the public id is one of those, so a direct get resolves it.
|
|
1098
1140
|
const live = jobs.get(id)?.job;
|
|
1099
1141
|
// A LIVE in-flight job in THIS process is authoritative (it's actively writing here) —
|
|
1100
|
-
// but decline if a concurrent live FOREIGN session shares the id with a different
|
|
1101
|
-
|
|
1102
|
-
|
|
1142
|
+
// but decline if a concurrent live FOREIGN session shares the id with a different
|
|
1143
|
+
// (trayId, target) identity.
|
|
1144
|
+
if (live &&
|
|
1145
|
+
(targetSelector === undefined || live.target === targetSelector) &&
|
|
1146
|
+
live.status === "downloading") {
|
|
1147
|
+
if (hasAmbiguousForeignSibling(id, live.trayId, live.target))
|
|
1103
1148
|
return undefined;
|
|
1104
1149
|
return live;
|
|
1105
1150
|
}
|
|
@@ -1109,10 +1154,21 @@ export function getDownloadJob(id, trayId) {
|
|
|
1109
1154
|
// same id. A cancel/error never lands a file, so it must never mask another session's
|
|
1110
1155
|
// validated-complete file (cancelled-over-complete), and this holds whether the
|
|
1111
1156
|
// cancelled/error record is in-memory or persisted.
|
|
1112
|
-
if (live &&
|
|
1157
|
+
if (live &&
|
|
1158
|
+
(targetSelector === undefined || live.target === targetSelector) &&
|
|
1159
|
+
live.status === "done")
|
|
1113
1160
|
return live;
|
|
1114
1161
|
const now = Date.now();
|
|
1115
|
-
const matches = listPersistedDownloadJobs().filter((r) => r.id === id);
|
|
1162
|
+
const matches = listPersistedDownloadJobs().filter((r) => r.id === id && (targetSelector === undefined || r.target === targetSelector));
|
|
1163
|
+
const freshIdentities = new Set(matches
|
|
1164
|
+
.filter((r) => r.status === "downloading" && now - (r.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS)
|
|
1165
|
+
.map((r) => `${r.id}\n${r.trayId}\n${r.target ?? ""}`));
|
|
1166
|
+
const settledIdentities = new Set(matches
|
|
1167
|
+
.filter((r) => r.status !== "downloading")
|
|
1168
|
+
.map((r) => `${r.id}\n${r.trayId}\n${r.target ?? ""}`));
|
|
1169
|
+
if (freshIdentities.size > 1 || (freshIdentities.size === 0 && settledIdentities.size > 1)) {
|
|
1170
|
+
return undefined;
|
|
1171
|
+
}
|
|
1116
1172
|
const persistedDone = matches
|
|
1117
1173
|
.filter((r) => r.status === "done")
|
|
1118
1174
|
.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0];
|
|
@@ -1123,11 +1179,12 @@ export function getDownloadJob(id, trayId) {
|
|
|
1123
1179
|
// record, then the newest persisted terminal.
|
|
1124
1180
|
const foreignLive = matches.filter((r) => r.status === "downloading" && now - (r.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
|
|
1125
1181
|
if (foreignLive.length > 0) {
|
|
1126
|
-
if (new Set(foreignLive.map((r) => r.trayId)).size > 1)
|
|
1182
|
+
if (new Set(foreignLive.map((r) => `${r.trayId}\n${r.target ?? ""}`)).size > 1)
|
|
1127
1183
|
return undefined; // ambiguous live
|
|
1128
1184
|
return jobFromPersisted(foreignLive.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0]);
|
|
1129
1185
|
}
|
|
1130
|
-
if (live
|
|
1186
|
+
if (live &&
|
|
1187
|
+
(targetSelector === undefined || live.target === targetSelector))
|
|
1131
1188
|
return live; // this session's terminal (cancelled/error), no DONE to prefer
|
|
1132
1189
|
const terminal = matches.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0];
|
|
1133
1190
|
return terminal ? jobFromPersisted(terminal) : undefined;
|
|
@@ -1158,8 +1215,15 @@ export function getDownloadJob(id, trayId) {
|
|
|
1158
1215
|
*/
|
|
1159
1216
|
export function describeUnresolvedDownload(id) {
|
|
1160
1217
|
const now = Date.now();
|
|
1161
|
-
const
|
|
1162
|
-
const
|
|
1218
|
+
const targetSelector = currentDownloadTarget();
|
|
1219
|
+
const liveInMemoryCandidate = jobs.get(id)?.job;
|
|
1220
|
+
const liveInMemory = liveInMemoryCandidate &&
|
|
1221
|
+
(targetSelector === undefined || liveInMemoryCandidate.target === targetSelector)
|
|
1222
|
+
? liveInMemoryCandidate
|
|
1223
|
+
: undefined;
|
|
1224
|
+
const inFlight = listPersistedDownloadJobs().filter((r) => r.id === id &&
|
|
1225
|
+
r.status === "downloading" &&
|
|
1226
|
+
(targetSelector === undefined || r.target === targetSelector));
|
|
1163
1227
|
const livePersisted = inFlight.filter((r) => now - (r.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
|
|
1164
1228
|
// A STALE in-flight record counts here, hedged — and getting that wrong is
|
|
1165
1229
|
// what would have left the REPORTED case uncovered.
|
|
@@ -1217,6 +1281,7 @@ export function describeUnresolvedDownload(id) {
|
|
|
1217
1281
|
export function findDownloadJob(query) {
|
|
1218
1282
|
const trayId = query.url ? downloadIdFor(query.url) : undefined;
|
|
1219
1283
|
const destKey = query.destKey;
|
|
1284
|
+
const targetSelector = query.target ?? currentDownloadTarget();
|
|
1220
1285
|
if (!trayId && !destKey)
|
|
1221
1286
|
return undefined;
|
|
1222
1287
|
// Gather candidate IN-FLIGHT jobs from BOTH this process's registry AND the cross-
|
|
@@ -1225,18 +1290,19 @@ export function findDownloadJob(query) {
|
|
|
1225
1290
|
// job for URL→destA plus another session's persisted in-flight job for URL→destB are
|
|
1226
1291
|
// two DISTINCT jobs for the same URL, and adopting either by URL alone would be a
|
|
1227
1292
|
// guess — so decline (the caller must use the exact id).
|
|
1228
|
-
// Key candidates by (id, trayId): two distinct URLs to one dest+auth share an id but
|
|
1293
|
+
// Key candidates by (id, trayId, target): two distinct URLs to one dest+auth share an id but
|
|
1229
1294
|
// differ in trayId (distinct physical downloads), so id alone would wrongly collapse
|
|
1230
1295
|
// them and mask the ambiguity. A live in-memory copy wins over its persisted snapshot
|
|
1231
1296
|
// (same id+trayId key).
|
|
1232
|
-
const keyOf = (id, tray) => `${id}\n${tray}`;
|
|
1297
|
+
const keyOf = (id, tray, targetValue) => `${id}\n${tray}\n${targetValue ?? ""}`;
|
|
1233
1298
|
const now = Date.now();
|
|
1234
1299
|
const inflightByKey = new Map();
|
|
1235
1300
|
for (const e of new Set(jobs.values())) {
|
|
1236
1301
|
if (e.job.status !== "downloading")
|
|
1237
1302
|
continue;
|
|
1238
|
-
if ((query.url && e.job.url === query.url) || (destKey && e.job.destKey === destKey))
|
|
1239
|
-
|
|
1303
|
+
if (((query.url && e.job.url === query.url) || (destKey && e.job.destKey === destKey)) &&
|
|
1304
|
+
(targetSelector === undefined || e.job.target === targetSelector)) {
|
|
1305
|
+
inflightByKey.set(keyOf(e.job.id, e.job.trayId, e.job.target), e.job);
|
|
1240
1306
|
}
|
|
1241
1307
|
}
|
|
1242
1308
|
for (const rec of listPersistedDownloadJobs()) {
|
|
@@ -1246,7 +1312,9 @@ export function findDownloadJob(query) {
|
|
|
1246
1312
|
if (rec.status !== "downloading" || now - (rec.updated ?? 0) >= PERSISTED_INFLIGHT_STALE_MS) {
|
|
1247
1313
|
continue;
|
|
1248
1314
|
}
|
|
1249
|
-
|
|
1315
|
+
if (targetSelector !== undefined && rec.target !== targetSelector)
|
|
1316
|
+
continue;
|
|
1317
|
+
const key = keyOf(rec.id, rec.trayId, rec.target);
|
|
1250
1318
|
if (inflightByKey.has(key))
|
|
1251
1319
|
continue;
|
|
1252
1320
|
if ((trayId && rec.trayId === trayId) || (destKey && rec.dest_key === destKey)) {
|
|
@@ -1259,7 +1327,7 @@ export function findDownloadJob(query) {
|
|
|
1259
1327
|
return [...inflightByKey.values()][0];
|
|
1260
1328
|
// No in-flight match anywhere — fall back to a single unambiguous SETTLED persisted
|
|
1261
1329
|
// record (for status reporting); findPersistedDownloadJob declines if ambiguous.
|
|
1262
|
-
const persisted = findPersistedDownloadJob({ trayId, destKey });
|
|
1330
|
+
const persisted = findPersistedDownloadJob({ trayId, destKey, target: targetSelector });
|
|
1263
1331
|
return persisted ? jobFromPersisted(persisted) : undefined;
|
|
1264
1332
|
}
|
|
1265
1333
|
/**
|
|
@@ -1287,12 +1355,11 @@ export function compareTrayIds(a, b) {
|
|
|
1287
1355
|
}
|
|
1288
1356
|
export function listDownloadJobs() {
|
|
1289
1357
|
// One Entry is indexed under multiple keys — dedup by identity so a job appears once
|
|
1290
|
-
// regardless of how many keys point at it. Identity is (id, trayId), NOT id alone:
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
// promises EVERY tracked download.
|
|
1358
|
+
// regardless of how many keys point at it. Identity is (id, trayId, target), NOT id alone:
|
|
1359
|
+
// concurrent local and pod transfers can share the first two fields and must remain
|
|
1360
|
+
// separately visible to the tray reconciliation and status action.
|
|
1294
1361
|
const seen = new Set();
|
|
1295
|
-
const keyOf = (j) => `${j.id}\n${j.trayId}`;
|
|
1362
|
+
const keyOf = (j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`;
|
|
1296
1363
|
const byKey = new Map();
|
|
1297
1364
|
for (const e of jobs.values()) {
|
|
1298
1365
|
if (seen.has(e))
|
|
@@ -1305,11 +1372,10 @@ export function listDownloadJobs() {
|
|
|
1305
1372
|
}
|
|
1306
1373
|
// #529: fold in persisted records for jobs THIS session's registry doesn't hold
|
|
1307
1374
|
// (started before a reconnect), so download_model action:"status" still lists in-flight downloads.
|
|
1308
|
-
// INTEGRITY TRUTH: a validated DONE
|
|
1309
|
-
// the SAME (id, trayId) — whether that other record is
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
// user's active action). The list never shows a cancelled in place of a validated file.
|
|
1375
|
+
// INTEGRITY TRUTH: a LIVE or validated DONE record WINS over a cancelled/error snapshot
|
|
1376
|
+
// for the SAME (id, trayId, target) — whether that other record is persisted or this session's
|
|
1377
|
+
// in-memory terminal entry. A live record must stay visible as the user's active action,
|
|
1378
|
+
// and the list must never replace a validated file with a terminal snapshot.
|
|
1313
1379
|
for (const rec of listPersistedDownloadJobs()) {
|
|
1314
1380
|
const job = jobFromPersisted(rec);
|
|
1315
1381
|
const k = keyOf(job);
|
|
@@ -1317,7 +1383,18 @@ export function listDownloadJobs() {
|
|
|
1317
1383
|
if (!cur) {
|
|
1318
1384
|
byKey.set(k, job);
|
|
1319
1385
|
}
|
|
1320
|
-
else if (job.status === "
|
|
1386
|
+
else if ((job.status === "downloading" &&
|
|
1387
|
+
job.staleInflight !== true &&
|
|
1388
|
+
cur.status !== "done" &&
|
|
1389
|
+
!(cur.status === "downloading" && cur.staleInflight !== true)) ||
|
|
1390
|
+
// A persisted terminal may replace a stale persisted in-flight snapshot, but
|
|
1391
|
+
// never an active in-memory writer. The latter has no heartbeat `updated` field;
|
|
1392
|
+
// comparing the terminal timestamp against zero made an old terminal shadow the
|
|
1393
|
+
// bytes that are still advancing in this process (#2057 recurrence).
|
|
1394
|
+
(job.status !== "downloading" &&
|
|
1395
|
+
cur.status === "downloading" &&
|
|
1396
|
+
cur.staleInflight === true) ||
|
|
1397
|
+
(job.status === "done" && cur.status !== "done" && cur.status !== "downloading")) {
|
|
1321
1398
|
byKey.set(k, job);
|
|
1322
1399
|
}
|
|
1323
1400
|
}
|
|
@@ -1504,6 +1581,7 @@ export async function adoptOrphanedDownloadJobs() {
|
|
|
1504
1581
|
if (routeToManager)
|
|
1505
1582
|
return adopted;
|
|
1506
1583
|
const now = Date.now();
|
|
1584
|
+
const target = currentDownloadTarget();
|
|
1507
1585
|
const records = listPersistedDownloadJobs();
|
|
1508
1586
|
// Ids this pass already re-issued. `records` is a SNAPSHOT taken before the loop,
|
|
1509
1587
|
// so without this a second dead record for the SAME id (two dead sessions each
|
|
@@ -1515,6 +1593,11 @@ export async function adoptOrphanedDownloadJobs() {
|
|
|
1515
1593
|
for (const rec of records) {
|
|
1516
1594
|
if (rec.status !== "downloading")
|
|
1517
1595
|
continue;
|
|
1596
|
+
// Adoption runs in the current target's process. A targetless record cannot
|
|
1597
|
+
// be used to adopt a local/pod transfer when the process has a real target,
|
|
1598
|
+
// and a targetful record must never be adopted by a targetless process.
|
|
1599
|
+
if (rec.target !== target)
|
|
1600
|
+
continue;
|
|
1518
1601
|
if (!rec.owner || rec.owner === PERSIST_OWNER)
|
|
1519
1602
|
continue;
|
|
1520
1603
|
if (rec.via_manager)
|
|
@@ -1523,10 +1606,13 @@ export async function adoptOrphanedDownloadJobs() {
|
|
|
1523
1606
|
continue;
|
|
1524
1607
|
if (!rec.url || downloadIdFor(rec.url) !== rec.trayId)
|
|
1525
1608
|
continue;
|
|
1526
|
-
|
|
1609
|
+
const identity = `${rec.id}\n${rec.trayId}\n${rec.target ?? ""}`;
|
|
1610
|
+
if (adoptedIds.has(identity))
|
|
1527
1611
|
continue;
|
|
1528
1612
|
const alreadyAdopted = records.some((other) => other !== rec &&
|
|
1529
1613
|
other.id === rec.id &&
|
|
1614
|
+
other.trayId === rec.trayId &&
|
|
1615
|
+
other.target === rec.target &&
|
|
1530
1616
|
other.status === "downloading" &&
|
|
1531
1617
|
now - (other.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
|
|
1532
1618
|
if (alreadyAdopted)
|
|
@@ -1565,7 +1651,7 @@ export async function adoptOrphanedDownloadJobs() {
|
|
|
1565
1651
|
resumedBytes: partial.bytes,
|
|
1566
1652
|
staleRecordLeft: reclaim.staleRecordLeft || undefined,
|
|
1567
1653
|
});
|
|
1568
|
-
adoptedIds.add(
|
|
1654
|
+
adoptedIds.add(identity);
|
|
1569
1655
|
}
|
|
1570
1656
|
return adopted;
|
|
1571
1657
|
}
|
|
@@ -1590,11 +1676,32 @@ export function cancelDownloadJob(id,
|
|
|
1590
1676
|
* it the composite identity (id, trayId) selects exactly one row, which is what
|
|
1591
1677
|
* made the stale job in #822 unselectable — and therefore unstoppable. */
|
|
1592
1678
|
trayId) {
|
|
1679
|
+
const targetSelector = currentDownloadTarget();
|
|
1593
1680
|
if (trayId) {
|
|
1681
|
+
const trayCandidates = listDownloadJobCandidates(id).filter((j) => j.trayId === trayId &&
|
|
1682
|
+
(targetSelector === undefined || j.target === targetSelector));
|
|
1683
|
+
if (targetSelector === undefined) {
|
|
1684
|
+
const identities = new Set(listDownloadJobCandidates(id)
|
|
1685
|
+
.filter((j) => j.trayId === trayId)
|
|
1686
|
+
.map((j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`));
|
|
1687
|
+
if (identities.size > 1) {
|
|
1688
|
+
return {
|
|
1689
|
+
found: true,
|
|
1690
|
+
owned: false,
|
|
1691
|
+
aborted: false,
|
|
1692
|
+
ambiguous: true,
|
|
1693
|
+
candidates: listDownloadJobCandidates(id),
|
|
1694
|
+
status: trayCandidates[0]?.status,
|
|
1695
|
+
job: trayCandidates[0],
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1594
1699
|
// Explicit selection: find the ONE local entry with this composite identity.
|
|
1595
1700
|
// Scanning entries (rather than jobs.get(id)) matters because the registry's
|
|
1596
1701
|
// id row holds only one of several same-destination jobs.
|
|
1597
|
-
const entry = [...new Set(jobs.values())].find((e) => e.job.id === id &&
|
|
1702
|
+
const entry = [...new Set(jobs.values())].find((e) => e.job.id === id &&
|
|
1703
|
+
e.job.trayId === trayId &&
|
|
1704
|
+
(targetSelector === undefined || e.job.target === targetSelector));
|
|
1598
1705
|
if (entry) {
|
|
1599
1706
|
const { job, controller } = entry;
|
|
1600
1707
|
// No ambiguity check here: the caller named the exact download, which is
|
|
@@ -1610,10 +1717,13 @@ trayId) {
|
|
|
1610
1717
|
// Not ours. It may be another session's (persisted). A LIVE one is reportable,
|
|
1611
1718
|
// not abortable — but a STALE one whose writer is proven gone is reclaimed
|
|
1612
1719
|
// (#858): closed as cancelled so the download can be safely re-issued.
|
|
1613
|
-
const foreign =
|
|
1720
|
+
const foreign = trayCandidates[0];
|
|
1614
1721
|
if (foreign) {
|
|
1615
1722
|
if (foreign.status === "downloading" && foreign.staleInflight) {
|
|
1616
|
-
const rec = listPersistedDownloadJobs().find((r) => r.id === id &&
|
|
1723
|
+
const rec = listPersistedDownloadJobs().find((r) => r.id === id &&
|
|
1724
|
+
r.trayId === trayId &&
|
|
1725
|
+
r.status === "downloading" &&
|
|
1726
|
+
(targetSelector === undefined || r.target === targetSelector));
|
|
1617
1727
|
if (rec) {
|
|
1618
1728
|
const re = reclaimDeadPersistedDownload(rec);
|
|
1619
1729
|
if (re.reclaimed) {
|
|
@@ -1642,12 +1752,16 @@ trayId) {
|
|
|
1642
1752
|
return { found: false, owned: false, aborted: false, candidates: listDownloadJobCandidates(id) };
|
|
1643
1753
|
}
|
|
1644
1754
|
const entry = jobs.get(id);
|
|
1645
|
-
|
|
1646
|
-
|
|
1755
|
+
const localEntry = entry &&
|
|
1756
|
+
(targetSelector === undefined || entry.job.target === targetSelector)
|
|
1757
|
+
? entry
|
|
1758
|
+
: undefined;
|
|
1759
|
+
if (localEntry) {
|
|
1760
|
+
const { job, controller } = localEntry;
|
|
1647
1761
|
// Cross-session ambiguity: a live foreign download shares this id with a DIFFERENT
|
|
1648
1762
|
// trayId. Aborting by id could act on the wrong logical download — decline so a
|
|
1649
1763
|
// cancel-by-id can never hit an unintended concurrent download (a #515 invariant).
|
|
1650
|
-
if (job.status === "downloading" && hasAmbiguousForeignSibling(id, job.trayId)) {
|
|
1764
|
+
if (job.status === "downloading" && hasAmbiguousForeignSibling(id, job.trayId, job.target)) {
|
|
1651
1765
|
return {
|
|
1652
1766
|
found: true,
|
|
1653
1767
|
owned: true,
|
|
@@ -1694,7 +1808,7 @@ trayId) {
|
|
|
1694
1808
|
// tray id of the one they mean. Only STILL-DOWNLOADING records create that
|
|
1695
1809
|
// ambiguity: a settled record cannot be cancelled wrongly (the call is a no-op
|
|
1696
1810
|
// report either way), so it must not block the one row that could be acted on.
|
|
1697
|
-
const persistedCandidates = listDownloadJobCandidates(id);
|
|
1811
|
+
const persistedCandidates = listDownloadJobCandidates(id).filter((candidate) => targetSelector === undefined || candidate.target === targetSelector);
|
|
1698
1812
|
if (persistedCandidates.filter((c) => c.status === "downloading").length > 1) {
|
|
1699
1813
|
return {
|
|
1700
1814
|
found: true,
|
|
@@ -1706,7 +1820,24 @@ trayId) {
|
|
|
1706
1820
|
job: persistedCandidates[0],
|
|
1707
1821
|
};
|
|
1708
1822
|
}
|
|
1709
|
-
const
|
|
1823
|
+
const persistedMatches = listPersistedDownloadJobs().filter((r) => r.id === id && (targetSelector === undefined || r.target === targetSelector));
|
|
1824
|
+
const persistedLive = persistedMatches.filter((r) => r.status === "downloading" && Date.now() - (r.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
|
|
1825
|
+
const persistedIdentities = new Set(persistedLive.map((r) => `${r.id}\n${r.trayId}\n${r.target ?? ""}`));
|
|
1826
|
+
if (persistedIdentities.size > 1) {
|
|
1827
|
+
return {
|
|
1828
|
+
found: true,
|
|
1829
|
+
owned: false,
|
|
1830
|
+
aborted: false,
|
|
1831
|
+
ambiguous: true,
|
|
1832
|
+
candidates: persistedCandidates,
|
|
1833
|
+
status: persistedCandidates[0]?.status,
|
|
1834
|
+
job: persistedCandidates[0],
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
const persistedPool = persistedMatches.filter((r) => r.status === "done");
|
|
1838
|
+
const persisted = (persistedLive[0] ?? (persistedPool.length > 0
|
|
1839
|
+
? persistedPool.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0]
|
|
1840
|
+
: persistedMatches.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0])) ?? null;
|
|
1710
1841
|
if (persisted) {
|
|
1711
1842
|
// A STALE in-flight record whose writer is proven gone is reclaimed (#858);
|
|
1712
1843
|
// a live or unprovable one stays refused exactly as before (#761).
|